smashed updated

This commit is contained in:
root 2025-10-16 16:23:36 +00:00
parent 77921ba6a8
commit 1da0b26320
14 changed files with 321 additions and 96 deletions

1
.gitignore vendored
View File

@ -4,7 +4,6 @@ venv
logs logs
sqlStorage sqlStorage
playground playground
alembic.ini
.DS_Store .DS_Store
messages.pot messages.pot
activeConfig activeConfig

View File

@ -1,3 +1,4 @@
import os
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import engine_from_config from sqlalchemy import engine_from_config
@ -7,6 +8,10 @@ from alembic import context
config = context.config 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: if config.config_file_name is not None:
fileConfig(config.config_file_name) fileConfig(config.config_file_name)

View File

@ -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_key in request.json, f"No {field_key} provided"
assert field_value(request.json[field_key]), f"Invalid {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 # Support legacy: 'content' as decrypted ContentId; and new: 'content' as encrypted IPFS CID
source_content_cid, cid_err = resolve_content(request.json['content']) source_content_cid, cid_err = resolve_content(request.json['content'])
assert not cid_err, f"Invalid content CID provided: {cid_err}" 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 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( metadata_content = await create_metadata_for_item(
request.ctx.db_session, 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, cover_url=f"{PROJECT_HOST}/api/v1.5/storage/{image_content_cid.serialize_v2()}" if image_content_cid else None,
authors=request.json['authors'], authors=request.json['authors'],
hashtags=request.json['hashtags'], hashtags=request.json['hashtags'],

View File

@ -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]: def _pick_primary_download(candidates: List[tuple[str, Optional[str], Optional[int]]]) -> Optional[str]:
priority = ( priority = (
'decrypted_high', 'decrypted_high',
'decrypted_original',
'decrypted_low', 'decrypted_low',
'decrypted_preview', 'decrypted_preview',
'high', 'high',
'low', 'low',
'preview', 'preview',
'original',
'stored',
) )
for target in priority: for target in priority:
for kind, url, _ in candidates: for kind, url, _ in candidates:
@ -656,6 +659,7 @@ async def s_api_v1_admin_uploads(request):
} }
search_parts: List[Any] = [ search_parts: List[Any] = [
content.artist,
content.title, content.title,
content.description, content.description,
content.encrypted_cid, content.encrypted_cid,
@ -697,6 +701,7 @@ async def s_api_v1_admin_uploads(request):
'metadata_cid': metadata_cid, 'metadata_cid': metadata_cid,
'content_hash': content_hash, 'content_hash': content_hash,
'title': content.title, 'title': content.title,
'artist': content.artist,
'description': content.description, 'description': content.description,
'content_type': content.content_type, 'content_type': content.content_type,
'size': { '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 {} metadata_candidate = stored_meta.get('metadata') if isinstance(stored_meta.get('metadata'), dict) else {}
title_candidates = [ title_candidates = [
stored_meta.get('title'), 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, 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, 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) 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: try:
cid_value = stored_content.cid.serialize_v2() cid_value = stored_content.cid.serialize_v2()
except Exception: except Exception:
@ -1354,7 +1370,8 @@ async def s_api_v1_admin_licenses(request):
'id': stored_content.id, 'id': stored_content.id,
'hash': stored_content.hash, 'hash': stored_content.hash,
'cid': cid_value, 'cid': cid_value,
'title': title_value or stored_content.hash, 'title': (title_value or stored_content.hash),
'artist': artist_value,
'type': stored_content.type, 'type': stored_content.type,
'owner_address': stored_content.owner_address, 'owner_address': stored_content.owner_address,
'onchain_index': stored_content.onchain_index, '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() ec = (await session.execute(select(EncryptedContent))).scalars().all()
backlog = 0 backlog = 0
for e in ec: 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 continue
kinds = [d.kind for d in deriv if d.content_id == e.id and d.status == 'ready'] 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(kinds):
if not req.issubset(set(kinds)):
backlog += 1 backlog += 1
try: try:
bs = await bitswap_stat() bs = await bitswap_stat()

View File

@ -2,7 +2,6 @@ from datetime import datetime, timedelta
from sanic import response from sanic import response
from sqlalchemy import select, and_, func from sqlalchemy import select, and_, func
from aiogram import Bot, types from aiogram import Bot, types
from sqlalchemy import and_
from app.core.logger import make_log from app.core.logger import make_log
from app.core.models._config import ServiceConfig from app.core.models._config import ServiceConfig
from app.core.models.node_storage import StoredContent 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) select(StoredContent).where(StoredContent.hash == cid.content_hash_b58)
)).scalars().first() )).scalars().first()
async def open_content_async(session, sc: StoredContent): 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: if not sc.encrypted:
decrypted = sc
encrypted = (await session.execute(select(StoredContent).where(StoredContent.decrypted_content_id == sc.id))).scalars().first() encrypted = (await session.execute(select(StoredContent).where(StoredContent.decrypted_content_id == sc.id))).scalars().first()
else: else:
encrypted = sc
decrypted = (await session.execute(select(StoredContent).where(StoredContent.id == sc.decrypted_content_id))).scalars().first() decrypted = (await session.execute(select(StoredContent).where(StoredContent.id == sc.decrypted_content_id))).scalars().first()
assert decrypted and encrypted, "Can't open content" if not encrypted:
ctype = decrypted.json_format().get('content_type', 'application/x-binary') 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: try:
content_type = ctype.split('/')[0] content_type = content_mime.split('/')[0]
except Exception: except Exception:
content_type = 'application' 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) 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 = { 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, 'content_address': license_address or master_address,
'license_address': license_address, 'license_address': license_address,
'master_address': master_address, 'master_address': master_address,
} }
if content['encrypted_content'].key_id: if encrypted_content.key_id:
known_key = (await request.ctx.db_session.execute( 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() )).scalars().first()
if known_key: if known_key:
opts['key_hash'] = known_key.seed_hash # нахер не нужно на данный момент 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: if request.ctx.user:
user_wallet_address = await request.ctx.user.wallet_address_async(request.ctx.db_session) user_wallet_address = await request.ctx.user.wallet_address_async(request.ctx.db_session)
have_access = ( 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( 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()) \ ))).scalars().first()) \
or bool((await request.ctx.db_session.execute(select(StarsInvoice).where( or bool((await request.ctx.db_session.execute(select(StarsInvoice).where(
and_( and_(
StarsInvoice.user_id == request.ctx.user.id, StarsInvoice.user_id == request.ctx.user.id,
StarsInvoice.content_hash == content['encrypted_content'].hash, StarsInvoice.content_hash == encrypted_content.hash,
StarsInvoice.paid == True StarsInvoice.paid == True
) )
))).scalars().first()) ))).scalars().first())
@ -122,7 +143,7 @@ async def s_api_v1_content_view(request, content_address: str):
if current_star_rate < 0: if current_star_rate < 0:
current_star_rate = 0.00000001 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]: if request.ctx.user.telegram_id in [5587262915, 6861699286]:
stars_cost = 2 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.user_id == request.ctx.user.id,
StarsInvoice.created > datetime.now() - timedelta(minutes=25), StarsInvoice.created > datetime.now() - timedelta(minutes=25),
StarsInvoice.amount == stars_cost, StarsInvoice.amount == stars_cost,
StarsInvoice.content_hash == content['encrypted_content'].hash, StarsInvoice.content_hash == encrypted_content.hash,
) )
))).scalars().first() ))).scalars().first()
if exist_invoice: if exist_invoice:
@ -154,7 +175,7 @@ async def s_api_v1_content_view(request, content_address: str):
type='access', type='access',
amount=stars_cost, amount=stars_cost,
user_id=request.ctx.user.id, user_id=request.ctx.user.id,
content_hash=content['encrypted_content'].hash, content_hash=encrypted_content.hash,
invoice_url=invoice_url invoice_url=invoice_url
) )
) )
@ -168,12 +189,17 @@ async def s_api_v1_content_view(request, content_address: str):
'amount': stars_cost, '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: if have_access:
opts['have_licenses'].append('listen') 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 ec_v3 = None
derivative_rows = [] derivative_rows = []
if enc_cid: if enc_cid:
@ -185,7 +211,7 @@ async def s_api_v1_content_view(request, content_address: str):
if enc_cid: if enc_cid:
upload_row = (await request.ctx.db_session.execute(select(UploadSession).where(UploadSession.encrypted_cid == enc_cid))).scalars().first() 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 = {} derivative_latest = {}
if derivative_rows: 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] file_hash = row.local_path.split('/')[-1]
return file_hash, f"{PROJECT_HOST}/api/v1.5/storage/{file_hash}" return file_hash, f"{PROJECT_HOST}/api/v1.5/storage/{file_hash}"
chosen_row = None preview_row = None
download_row = None
if have_access: 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: 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 break
else: 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: if key in derivative_latest:
chosen_row = derivative_latest[key] preview_row = derivative_latest[key]
break break
if chosen_row: if preview_row:
file_hash, url = _row_to_hash_and_url(chosen_row) file_hash, url = _row_to_hash_and_url(preview_row)
if url: if url:
display_options['content_url'] = url display_options['content_url'] = url
opts['content_ext'] = (chosen_row.content_type or '').split('/')[-1] if chosen_row.content_type else None if preview_row.content_type and not opts.get('content_ext'):
converted_meta_map.setdefault('low' if have_access else 'low_preview', file_hash) 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: if not display_options['content_url'] and converted_meta_map:
preference = ['low', 'high', 'low_preview'] if have_access else ['low_preview', 'low', 'high'] 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() stored = (await request.ctx.db_session.execute(select(StoredContent).where(StoredContent.hash == hash_value))).scalars().first()
if stored: if stored:
display_options['content_url'] = stored.web_url 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 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 # Metadata fallback
content_meta = content['encrypted_content'].json_format() content_meta = encrypted_content.json_format()
content_metadata_json = None content_metadata_json = None
_mcid = content_meta.get('metadata_cid') or None _mcid = content_meta.get('metadata_cid') or None
if _mcid: if _mcid:
@ -247,20 +323,58 @@ async def s_api_v1_content_view(request, content_address: str):
if not content_metadata_json: 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_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_description = (ec_v3.description if ec_v3 else '') or ''
fallback_artist = content_meta.get('artist') or None
content_metadata_json = { content_metadata_json = {
'name': fallback_name or 'Без названия', 'name': fallback_name or 'Без названия',
'title': fallback_name or 'Без названия',
'artist': fallback_artist,
'description': fallback_description, 'description': fallback_description,
'downloadable': False, 'downloadable': False,
} }
cover_cid = content_meta.get('cover_cid') cover_cid = content_meta.get('cover_cid')
if cover_cid: if cover_cid:
content_metadata_json.setdefault('image', f"{PROJECT_HOST}/api/v1.5/storage/{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['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) base_downloadable = content_metadata_json.get('downloadable', False)
if opts['downloadable'] and 'listen' not in opts['have_licenses']: if content_kind == 'other':
opts['downloadable'] = False base_downloadable = True
opts['downloadable'] = bool(display_options.get('download_url')) and base_downloadable and have_access
# Conversion status summary # Conversion status summary
conversion_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, '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'} effective_mime = (ec_v3.content_type if ec_v3 and ec_v3.content_type else content_mime) or ''
if ec_v3 and ec_v3.content_type.startswith('video/'): if effective_mime.startswith('audio/'):
required_kinds.add('decrypted_preview') 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} statuses_by_kind = {kind: row.status for kind, row in derivative_summary_map.items() if kind in required_kinds}
conversion_state = 'pending' conversion_state = 'pending'
@ -293,7 +411,7 @@ async def s_api_v1_content_view(request, content_address: str):
elif statuses_by_kind: elif statuses_by_kind:
conversion_state = 'partial' 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' conversion_state = 'ready'
upload_info = None 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, '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': if final_state != 'ready':
upload_state = upload_row.state if upload_row else None upload_state = upload_row.state if upload_row else None
if conversion_state == 'failed' or upload_state in ('failed', 'conversion_failed'): 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, '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({ return response.json({
**opts, **opts,
'encrypted': content['encrypted_content'].json_format(), 'encrypted': encrypted_payload,
'display_options': display_options, 'display_options': display_options,
}) })

View File

@ -27,9 +27,12 @@ async def s_api_v1_upload_status(request, upload_id: str):
{"kind": kind, "status": status} {"kind": kind, "status": status}
for kind, status in derivative_rows for kind, status in derivative_rows
] ]
required = {"decrypted_high", "decrypted_low"} if ec.content_type and ec.content_type.startswith("audio/"):
if ec.preview_enabled and ec.content_type.startswith("video/"): required = {"decrypted_high", "decrypted_low"}
required.add("decrypted_preview") 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} statuses = {kind: status for kind, status in derivative_rows}
if required and all(statuses.get(k) == "ready" for k in required): if required and all(statuses.get(k) == "ready" for k in required):
conv_state = "ready" conv_state = "ready"

View File

@ -69,6 +69,7 @@ async def s_api_v1_upload_tus_hook(request):
meta = upload.get("MetaData") or {} meta = upload.get("MetaData") or {}
# Common metadata keys # Common metadata keys
title = meta.get("title") or meta.get("Title") or meta.get("name") or "Untitled" 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 "" description = meta.get("description") or meta.get("Description") or ""
content_type = meta.get("content_type") or meta.get("Content-Type") or "application/octet-stream" 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/") 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( ec = EncryptedContent(
encrypted_cid=encrypted_cid, encrypted_cid=encrypted_cid,
title=title, title=title,
artist=artist or None,
description=description, description=description,
content_type=content_type, content_type=content_type,
enc_size_bytes=enc_size, enc_size_bytes=enc_size,
@ -196,7 +198,9 @@ async def s_api_v1_upload_tus_hook(request):
'storage': 'ipfs', 'storage': 'ipfs',
'encrypted_cid': encrypted_cid, 'encrypted_cid': encrypted_cid,
'upload_id': upload_id, 'upload_id': upload_id,
'source': 'tusd' 'source': 'tusd',
'title': title,
'artist': artist or None,
} }
encrypted_stored_content = StoredContent( encrypted_stored_content = StoredContent(
type="local/encrypted_ipfs", type="local/encrypted_ipfs",
@ -218,12 +222,14 @@ async def s_api_v1_upload_tus_hook(request):
"encrypted_cid": encrypted_cid, "encrypted_cid": encrypted_cid,
"title": title, "title": title,
"description": description, "description": description,
"artist": artist,
"content_type": content_type, "content_type": content_type,
"size_bytes": enc_size, "size_bytes": enc_size,
"preview_enabled": preview_enabled, "preview_enabled": preview_enabled,
"preview_conf": ec.preview_conf, "preview_conf": ec.preview_conf,
"issuer_node_id": key_fpr, "issuer_node_id": key_fpr,
"salt_b64": _b64(salt), "salt_b64": _b64(salt),
"artist": artist or None,
} }
try: try:
from app.core._crypto.signer import Signer from app.core._crypto.signer import Signer

View File

@ -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, '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('audio/'):
if content_type.startswith('video/'): required = {'decrypted_low', 'decrypted_high'}
required.add('decrypted_preview') 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} statuses_by_kind = {kind: derivative_latest[kind].status for kind in required if kind in derivative_latest}
conversion_state = 'pending' conversion_state = 'pending'

View File

@ -8,7 +8,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import List, Optional, Tuple 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.logger import make_log
from app.core.storage import db_session 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 plain_filename = f"{ec.encrypted_cid}.{input_ext}" if input_ext else ec.encrypted_cid
async with db_session() as session: async with db_session() as session:
existing = (await session.execute(select(StoredContent).where(StoredContent.hash == file_hash))).scalars().first() 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( sc = StoredContent(
type="local/content_bin", type="local/content_bin",
hash=file_hash, hash=file_hash,
user_id=None, user_id=None,
filename=plain_filename, 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(), created=datetime.utcnow(),
) )
session.add(sc) session.add(sc)
await session.flush() 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( derivative = ContentDerivative(
content_id=ec.id, content_id=ec.id,
kind='decrypted_original', 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 def _pick_pending(limit: int) -> List[Tuple[EncryptedContent, PlainStaging]]:
async with db_session() as session: async with db_session() as session:
# Find A/V contents with preview_enabled and no ready low/low_preview derivatives yet # Include preview-enabled media and non-media content that need decrypted originals
ecs = (await session.execute(select(EncryptedContent).where( non_media_filter = and_(
EncryptedContent.preview_enabled == True EncryptedContent.content_type.isnot(None),
).order_by(EncryptedContent.created_at.desc()))).scalars().all() ~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]] = [] picked: List[Tuple[EncryptedContent, PlainStaging]] = []
for ec in ecs: for ec in ecs:
@ -365,7 +400,12 @@ async def _pick_pending(limit: int) -> List[Tuple[EncryptedContent, PlainStaging
# Check if derivatives already ready # Check if derivatives already ready
rows = (await session.execute(select(ContentDerivative).where(ContentDerivative.content_id == ec.id))).scalars().all() 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'} 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): if required.issubset(kinds_ready):
continue continue
# Always decrypt from IPFS using local or remote key # Always decrypt from IPFS using local or remote key

View File

@ -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 wallet_owner_user = await session.get(User, wallet_owner_connection.user_id) if wallet_owner_connection else None
if wallet_owner_user.telegram_id: 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) 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( await wallet_owner_bot.send_message(
user.translated('p_licenseWasBought').format( user.translated('p_licenseWasBought').format(
username=user.front_format(), username=user.front_format(),
nft_address=f'"https://tonviewer.com/{new_license.onchain_address}"', nft_address=f'"https://tonviewer.com/{new_license.onchain_address}"',
content_title=content_metadata.get('name', 'Unknown'), content_title=formatted_title,
), ),
message_type='notification', message_type='notification',
) )

View File

@ -115,6 +115,7 @@ def _clean_text_content(text: str, is_hashtag: bool = False) -> str:
async def create_metadata_for_item( async def create_metadata_for_item(
db_session, db_session,
title: str = None, title: str = None,
artist: str = None,
cover_url: str = None, cover_url: str = None,
authors: list = None, authors: list = None,
hashtags: list = [], hashtags: list = [],
@ -128,6 +129,15 @@ async def create_metadata_for_item(
cleaned_title = cleaned_title[:100].strip() # Truncate and strip after cleaning 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." 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 # Process and clean hashtags
processed_hashtags = [] processed_hashtags = []
if hashtags and isinstance(hashtags, list): if hashtags and isinstance(hashtags, list):
@ -142,17 +152,21 @@ async def create_metadata_for_item(
processed_hashtags = list(dict.fromkeys(processed_hashtags))[:10] processed_hashtags = list(dict.fromkeys(processed_hashtags))[:10]
item_metadata = { item_metadata = {
'name': cleaned_title, 'name': display_name,
'attributes': [ 'title': cleaned_title,
# { 'display_name': display_name,
# 'trait_type': 'Artist',
# 'value': 'Unknown'
# },
],
'downloadable': downloadable, 'downloadable': downloadable,
'tags': processed_hashtags, # New field for storing the list of cleaned hashtags '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 # Generate description from the processed hashtags
item_metadata['description'] = ' '.join([f"#{h}" for h in processed_hashtags if h]) item_metadata['description'] = ' '.join([f"#{h}" for h in processed_hashtags if h])

View File

@ -1,4 +1,3 @@
import html
from sqlalchemy import and_, select from sqlalchemy import and_, select
from app.core.models.node_storage import StoredContent from app.core.models.node_storage import StoredContent
from app.core.models.content.user_content import UserContent, UserAction from app.core.models.content.user_content import UserContent, UserAction
@ -26,7 +25,6 @@ class PlayerTemplates:
text = "" text = ""
content_metadata_json = {} content_metadata_json = {}
description_block = "" description_block = ""
status_hint = ""
if content: if content:
assert content.type.startswith('onchain/content'), "Invalid nodeStorage content type" assert content.type.startswith('onchain/content'), "Invalid nodeStorage content type"
cd_log = f"Content (SHA256: {content.hash}), Encrypted: {content.encrypted}, TelegramCID: {content.telegram_cid}. " cd_log = f"Content (SHA256: {content.hash}), Encrypted: {content.encrypted}, TelegramCID: {content.telegram_cid}. "
@ -114,9 +112,6 @@ class PlayerTemplates:
if encrypted_content_row: if encrypted_content_row:
break break
if not local_content:
status_hint = self.user.translated('p_playerContext_contentNotReady')
description = (content_metadata_json.get('description') or '').strip() 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 '' encrypted_description = (encrypted_content_row.description or '').strip() if encrypted_content_row and encrypted_content_row.description else ''
if not description and encrypted_description: if not description and encrypted_description:
@ -124,18 +119,24 @@ class PlayerTemplates:
if description: if description:
description_block = f"{description}\n" description_block = f"{description}\n"
title = ( metadata_title = content_metadata_json.get('title') or content_metadata_json.get('name')
content_metadata_json.get('name') if not metadata_title:
or (encrypted_content_row.title if encrypted_content_row and encrypted_content_row.title else None) metadata_title = (
or (local_content.filename if local_content else None) (encrypted_content_row.title if encrypted_content_row and encrypted_content_row.title else None)
or (content.filename if content else None) or (local_content.filename if local_content else None)
or content.cid.serialize_v2() or (content.filename if content else None)
) or content.cid.serialize_v2()
)
status_block = f"{status_hint}\n" if status_hint else "" 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"""<b>{title}</b> text = f"""<b>{title}</b>
{description_block}{status_block}Этот контент был загружен в MY {description_block}Этот контент был загружен в MY
\t/ p2p content market / \t/ p2p content market /
<blockquote><a href="{content_share_link['url']}">🔴 «открыть в MY»</a></blockquote>""" <blockquote><a href="{content_share_link['url']}">🔴 «открыть в MY»</a></blockquote>"""
@ -152,16 +153,7 @@ class PlayerTemplates:
) )
)).scalars().all() )).scalars().all()
if not local_content: if local_content and processing_messages:
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:
for msg in processing_messages: for msg in processing_messages:
await self.delete_message(msg.message_id) await self.delete_message(msg.message_id)

View File

@ -16,6 +16,7 @@ class EncryptedContent(AlchemyBase):
# Public metadata # Public metadata
title = Column(String(512), nullable=False) title = Column(String(512), nullable=False)
artist = Column(String(512), nullable=True)
description = Column(String(4096), nullable=True) description = Column(String(4096), nullable=True)
content_type = Column(String(64), nullable=False) # e.g. audio/flac, video/mp4, application/octet-stream content_type = Column(String(64), nullable=False) # e.g. audio/flac, video/mp4, application/octet-stream

View File

@ -18,3 +18,4 @@ pillow==10.2.0
ffmpeg-python==0.2.0 ffmpeg-python==0.2.0
python-magic==0.4.27 python-magic==0.4.27
cryptography==42.0.5 cryptography==42.0.5
alembic==1.13.1