diff --git a/app/api/routes/content.py b/app/api/routes/content.py index c5db81c..323e8c8 100644 --- a/app/api/routes/content.py +++ b/app/api/routes/content.py @@ -79,12 +79,18 @@ async def s_api_v1_content_view(request, content_address: str): content_type = ctype.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': ctype, + } content = await open_content_async(request.ctx.db_session, r_content) master_address = content['encrypted_content'].meta.get('item_address', '') opts = { 'content_type': content['content_type'], # возможно с ошибками, нужно переделать на ffprobe + 'content_mime': content.get('content_mime'), 'content_address': license_address or master_address, 'license_address': license_address, 'master_address': master_address, @@ -180,12 +186,21 @@ async def s_api_v1_content_view(request, content_address: str): '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: 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 derivative_rows = [] 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 {}) + 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 = {} if derivative_rows: 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] 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 - 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'): if key in derivative_latest: chosen_row = derivative_latest[key] @@ -227,11 +273,26 @@ async def s_api_v1_content_view(request, content_address: str): file_hash, url = _row_to_hash_and_url(chosen_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) + ext_candidate = None + 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: - preference = ['low', 'high', 'low_preview'] if have_access else ['low_preview', 'low', 'high'] + if content_kind == 'binary': + preference = ['original'] if have_access else [] + else: + preference = ['low', 'high', 'low_preview'] if have_access else ['low_preview', 'low', 'high'] for key in preference: hash_value = converted_meta_map.get(key) if not hash_value: @@ -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() if stored: 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 # Metadata fallback - content_meta = content['encrypted_content'].json_format() + content_meta = encrypted_json content_metadata_json = None _mcid = content_meta.get('metadata_cid') or None if _mcid: @@ -290,9 +357,14 @@ 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') + 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'} + if ec_v3 and ec_v3.content_type and ec_v3.content_type.startswith('video/'): + required_kinds.add('decrypted_preview') statuses_by_kind = {kind: row.status for kind, row in derivative_summary_map.items() if kind in required_kinds} conversion_state = 'pending' @@ -318,15 +390,15 @@ 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 - if final_state != 'ready': - upload_state = upload_row.state if upload_row else None - if conversion_state == 'failed' or upload_state in ('failed', 'conversion_failed'): - final_state = 'failed' - elif conversion_state in ('processing', 'partial') or upload_state in ('processing', 'pinned'): - final_state = 'processing' - else: - final_state = 'uploaded' + upload_state = upload_row.state if upload_row else None + if conversion_state == 'failed' or upload_state in ('failed', 'conversion_failed'): + final_state = 'failed' + elif conversion_state == 'ready': + final_state = 'ready' + elif conversion_state in ('processing', 'partial') or upload_state in ('processing', 'pinned'): + final_state = 'processing' + else: + final_state = 'uploaded' conversion_info = { 'state': conversion_state, @@ -341,7 +413,10 @@ async def s_api_v1_content_view(request, content_address: str): 'state': final_state, 'conversion_state': conversion_state, '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({ **opts, diff --git a/app/api/routes/upload_tus.py b/app/api/routes/upload_tus.py index d0970d1..a71c1c3 100644 --- a/app/api/routes/upload_tus.py +++ b/app/api/routes/upload_tus.py @@ -9,6 +9,7 @@ from typing import Dict, Any import aiofiles from base58 import b58encode from sanic import response +import magic # type: ignore from app.core._config import UPLOADS_DIR, PROJECT_HOST from app.core._secrets import hot_pubkey @@ -73,7 +74,37 @@ async def s_api_v1_upload_tus_hook(request): 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/") + 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 try: start_ms = int(meta.get("preview_start_ms") or 0) diff --git a/app/core/models/transaction.py b/app/core/models/transaction.py index 21fa1fc..1a7be98 100644 --- a/app/core/models/transaction.py +++ b/app/core/models/transaction.py @@ -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 datetime import datetime @@ -49,7 +49,7 @@ class StarsInvoice(AlchemyBase): user_id = Column(Integer, ForeignKey('users.id'), 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) paid = Column(Boolean, nullable=False, default=False)