Compare commits

...

3 Commits

Author SHA1 Message Date
root 01bb82fa5a updates 2025-10-20 15:31:52 +00:00
root 0405c340a3 Merge branch 'origin/master' 2025-10-16 16:49:11 +00:00
root 1da0b26320 smashed updated 2025-10-16 16:23:36 +00:00
15 changed files with 290 additions and 80 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

@ -217,11 +217,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:
@ -764,6 +767,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,
@ -805,6 +809,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': {
@ -1513,10 +1518,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:
@ -1525,7 +1541,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,
@ -2097,11 +2114,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()
continue if ctype.startswith('audio/'):
kinds = [d.kind for d in deriv if d.content_id == e.id and d.status == 'ready'] req = {'decrypted_low', 'decrypted_high'}
elif ctype.startswith('video/'):
req = {'decrypted_low', 'decrypted_high', 'decrypted_preview'} req = {'decrypted_low', 'decrypted_high', 'decrypted_preview'}
if not req.issubset(set(kinds)): 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'}
if not req.issubset(kinds):
backlog += 1 backlog += 1
try: try:
bs = await bitswap_stat() bs = await bitswap_stat()

View File

@ -79,12 +79,18 @@ async def s_api_v1_content_view(request, content_address: str):
content_type = ctype.split('/')[0] content_type = ctype.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': ctype,
}
content = await open_content_async(request.ctx.db_session, r_content) content = await open_content_async(request.ctx.db_session, r_content)
master_address = content['encrypted_content'].meta.get('item_address', '') master_address = content['encrypted_content'].meta.get('item_address', '')
opts = { opts = {
'content_type': content['content_type'], # возможно с ошибками, нужно переделать на ffprobe 'content_type': content['content_type'], # возможно с ошибками, нужно переделать на ffprobe
'content_mime': content.get('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,
@ -180,12 +186,21 @@ 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,
'content_kind': None,
'has_preview': False,
'original_available': False,
'requires_license': False,
}
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') encrypted_json = content['encrypted_content'].json_format()
decrypted_json = content['decrypted_content'].json_format()
enc_cid = encrypted_json.get('content_cid') or encrypted_json.get('encrypted_cid')
ec_v3 = None ec_v3 = None
derivative_rows = [] derivative_rows = []
if enc_cid: if enc_cid:
@ -199,6 +214,30 @@ async def s_api_v1_content_view(request, content_address: str):
converted_meta_map = dict(content['encrypted_content'].meta.get('converted_content') or {}) converted_meta_map = dict(content['encrypted_content'].meta.get('converted_content') or {})
content_mime = (
(ec_v3.content_type if ec_v3 and ec_v3.content_type else None)
or decrypted_json.get('content_type')
or encrypted_json.get('content_type')
or opts.get('content_mime')
or 'application/octet-stream'
)
opts['content_mime'] = content_mime
try:
opts['content_type'] = content_mime.split('/')[0]
except Exception:
opts['content_type'] = opts.get('content_type') or 'application'
content_kind = 'audio'
if content_mime.startswith('video/'):
content_kind = 'video'
elif content_mime.startswith('audio/'):
content_kind = 'audio'
else:
content_kind = 'binary'
display_options['content_kind'] = content_kind
display_options['requires_license'] = (not have_access) and content_kind == 'binary'
derivative_latest = {} derivative_latest = {}
if derivative_rows: if derivative_rows:
derivative_sorted = sorted(derivative_rows, key=lambda row: row.created_at or datetime.min) derivative_sorted = sorted(derivative_rows, key=lambda row: row.created_at or datetime.min)
@ -211,8 +250,15 @@ 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}"
has_preview = bool(derivative_latest.get('decrypted_preview') or converted_meta_map.get('low_preview'))
display_options['has_preview'] = has_preview
display_options['original_available'] = bool(derivative_latest.get('decrypted_original') or converted_meta_map.get('original'))
chosen_row = None chosen_row = None
if have_access: if content_kind == 'binary':
if have_access and 'decrypted_original' in derivative_latest:
chosen_row = derivative_latest['decrypted_original']
elif have_access:
for key in ('decrypted_low', 'decrypted_high'): for key in ('decrypted_low', 'decrypted_high'):
if key in derivative_latest: if key in derivative_latest:
chosen_row = derivative_latest[key] chosen_row = derivative_latest[key]
@ -227,10 +273,25 @@ async def s_api_v1_content_view(request, content_address: str):
file_hash, url = _row_to_hash_and_url(chosen_row) file_hash, url = _row_to_hash_and_url(chosen_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 ext_candidate = None
converted_meta_map.setdefault('low' if have_access else 'low_preview', file_hash) if chosen_row.content_type:
ext_candidate = chosen_row.content_type.split('/')[-1]
elif '/' in content_mime:
ext_candidate = content_mime.split('/')[-1]
if ext_candidate:
opts['content_ext'] = ext_candidate
if content_kind == 'binary':
display_options['original_available'] = True
converted_meta_map.setdefault('original', file_hash)
elif have_access:
converted_meta_map.setdefault('low', file_hash)
else:
converted_meta_map.setdefault('low_preview', file_hash)
if not display_options['content_url'] and converted_meta_map: if not display_options['content_url'] and converted_meta_map:
if content_kind == 'binary':
preference = ['original'] if have_access else []
else:
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']
for key in preference: for key in preference:
hash_value = converted_meta_map.get(key) hash_value = converted_meta_map.get(key)
@ -239,11 +300,17 @@ 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] filename = stored.filename or ''
if '.' in filename:
opts['content_ext'] = filename.split('.')[-1]
elif '/' in content_mime:
opts['content_ext'] = content_mime.split('/')[-1]
if content_kind == 'binary':
display_options['original_available'] = True
break break
# Metadata fallback # Metadata fallback
content_meta = content['encrypted_content'].json_format() content_meta = encrypted_json
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:
@ -290,8 +357,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 = set()
if content_kind == 'binary':
if derivative_latest.get('decrypted_original') or converted_meta_map.get('original'):
required_kinds.add('decrypted_original')
else:
required_kinds = {'decrypted_low', 'decrypted_high'} required_kinds = {'decrypted_low', 'decrypted_high'}
if ec_v3 and ec_v3.content_type.startswith('video/'): if ec_v3 and ec_v3.content_type and ec_v3.content_type.startswith('video/'):
required_kinds.add('decrypted_preview') required_kinds.add('decrypted_preview')
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}
@ -318,11 +390,11 @@ 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
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'):
final_state = 'failed' final_state = 'failed'
elif conversion_state == 'ready':
final_state = 'ready'
elif conversion_state in ('processing', 'partial') or upload_state in ('processing', 'pinned'): elif conversion_state in ('processing', 'partial') or upload_state in ('processing', 'pinned'):
final_state = 'processing' final_state = 'processing'
else: else:
@ -341,7 +413,10 @@ async def s_api_v1_content_view(request, content_address: str):
'state': final_state, 'state': final_state,
'conversion_state': conversion_state, 'conversion_state': conversion_state,
'upload_state': upload_info['state'] if upload_info else None, 'upload_state': upload_info['state'] if upload_info else None,
'has_access': have_access,
} }
if not opts.get('content_ext') and '/' in content_mime:
opts['content_ext'] = content_mime.split('/')[-1]
return response.json({ return response.json({
**opts, **opts,

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
] ]
if ec.content_type and ec.content_type.startswith("audio/"):
required = {"decrypted_high", "decrypted_low"} required = {"decrypted_high", "decrypted_low"}
if ec.preview_enabled and ec.content_type.startswith("video/"): elif ec.content_type and ec.content_type.startswith("video/"):
required.add("decrypted_preview") 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

@ -9,6 +9,7 @@ from typing import Dict, Any
import aiofiles import aiofiles
from base58 import b58encode from base58 import b58encode
from sanic import response from sanic import response
import magic # type: ignore
from app.core._config import UPLOADS_DIR, PROJECT_HOST from app.core._config import UPLOADS_DIR, PROJECT_HOST
from app.core._secrets import hot_pubkey from app.core._secrets import hot_pubkey
@ -70,9 +71,40 @@ 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/") detected_content_type = None
try:
raw_detected = magic.from_file(file_path, mime=True)
if raw_detected:
detected_content_type = raw_detected.split(";")[0].strip()
except Exception as e:
make_log("tus-hook", f"magic MIME detection failed for {file_path}: {e}", level="warning")
def _is_av(mime: str | None) -> bool:
if not mime:
return False
return mime.startswith("audio/") or mime.startswith("video/")
if detected_content_type:
if not _is_av(detected_content_type):
if content_type != detected_content_type:
make_log(
"tus-hook",
f"Overriding declared content_type '{content_type}' with detected '{detected_content_type}' (binary upload)",
level="info",
)
content_type = detected_content_type
elif not _is_av(content_type):
make_log(
"tus-hook",
f"Detected audio/video MIME '{detected_content_type}' replacing non-AV declaration '{content_type}'",
level="info",
)
content_type = detected_content_type
preview_enabled = _is_av(content_type)
# Optional preview window overrides from tus metadata # Optional preview window overrides from tus metadata
try: try:
start_ms = int(meta.get("preview_start_ms") or 0) start_ms = int(meta.get("preview_start_ms") or 0)
@ -156,6 +188,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,
@ -197,7 +230,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",
@ -219,12 +254,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,
}) })
if content_type.startswith('audio/'):
required = {'decrypted_low', 'decrypted_high'} required = {'decrypted_low', 'decrypted_high'}
if content_type.startswith('video/'): elif content_type.startswith('video/'):
required.add('decrypted_preview') 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

@ -87,11 +87,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 = (
(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 (local_content.filename if local_content else None)
or (content.filename if content else None) or (content.filename if content else None)
or content.cid.serialize_v2() or content.cid.serialize_v2()
) )
metadata_artist = content_metadata_json.get('artist')
status_block = f"{status_hint}\n" if status_hint else "" 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

@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, Float from sqlalchemy import Column, Integer, BigInteger, String, ForeignKey, DateTime, Boolean, Float
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
@ -49,7 +49,7 @@ class StarsInvoice(AlchemyBase):
user_id = Column(Integer, ForeignKey('users.id'), nullable=True) user_id = Column(Integer, ForeignKey('users.id'), nullable=True)
content_hash = Column(String(256), nullable=True) content_hash = Column(String(256), nullable=True)
telegram_id = Column(Integer, nullable=True) telegram_id = Column(BigInteger, nullable=True)
invoice_url = Column(String(256), nullable=True) invoice_url = Column(String(256), nullable=True)
paid = Column(Boolean, nullable=False, default=False) paid = Column(Boolean, nullable=False, default=False)

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