Compare commits
No commits in common. "01bb82fa5a63ed78b897a32ba0df1edfee73fc44" and "f140181c45aebc55b4dd5bbd6db7703b1fb11cc6" have entirely different histories.
01bb82fa5a
...
f140181c45
|
|
@ -4,6 +4,7 @@ venv
|
||||||
logs
|
logs
|
||||||
sqlStorage
|
sqlStorage
|
||||||
playground
|
playground
|
||||||
|
alembic.ini
|
||||||
.DS_Store
|
.DS_Store
|
||||||
messages.pot
|
messages.pot
|
||||||
activeConfig
|
activeConfig
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import os
|
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
|
||||||
from sqlalchemy import engine_from_config
|
from sqlalchemy import engine_from_config
|
||||||
|
|
@ -8,10 +7,6 @@ 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,15 +56,6 @@ 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}"
|
||||||
|
|
@ -94,16 +85,11 @@ async def s_api_v1_blockchain_send_new_content_message(request):
|
||||||
image_content = None
|
image_content = None
|
||||||
|
|
||||||
|
|
||||||
content_title = request.json['title']
|
content_title = f"{', '.join(request.json['authors'])} – {request.json['title']}" if request.json['authors'] else 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=request.json['title'],
|
title=content_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'],
|
||||||
|
|
|
||||||
|
|
@ -217,14 +217,11 @@ 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:
|
||||||
|
|
@ -767,7 +764,6 @@ 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,
|
||||||
|
|
@ -809,7 +805,6 @@ 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': {
|
||||||
|
|
@ -1518,21 +1513,10 @@ 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:
|
||||||
|
|
@ -1541,8 +1525,7 @@ 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,
|
||||||
|
|
@ -2114,17 +2097,11 @@ 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:
|
||||||
ctype = (e.content_type or '').lower()
|
if not e.preview_enabled:
|
||||||
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']
|
||||||
if not req.issubset(kinds):
|
req = {'decrypted_low', 'decrypted_high', 'decrypted_preview'}
|
||||||
|
if not req.issubset(set(kinds)):
|
||||||
backlog += 1
|
backlog += 1
|
||||||
try:
|
try:
|
||||||
bs = await bitswap_stat()
|
bs = await bitswap_stat()
|
||||||
|
|
|
||||||
|
|
@ -79,18 +79,12 @@ 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 {
|
return {'encrypted_content': encrypted, 'decrypted_content': decrypted, 'content_type': content_type}
|
||||||
'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,
|
||||||
|
|
@ -186,21 +180,12 @@ async def s_api_v1_content_view(request, content_address: str):
|
||||||
'amount': stars_cost,
|
'amount': stars_cost,
|
||||||
}
|
}
|
||||||
|
|
||||||
display_options = {
|
display_options = {'content_url': None}
|
||||||
'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')
|
||||||
|
|
||||||
encrypted_json = content['encrypted_content'].json_format()
|
enc_cid = content['encrypted_content'].meta.get('content_cid') or content['encrypted_content'].meta.get('encrypted_cid')
|
||||||
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:
|
||||||
|
|
@ -214,30 +199,6 @@ 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)
|
||||||
|
|
@ -250,15 +211,8 @@ 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 content_kind == 'binary':
|
if have_access:
|
||||||
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]
|
||||||
|
|
@ -273,26 +227,11 @@ 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
|
||||||
ext_candidate = None
|
opts['content_ext'] = (chosen_row.content_type or '').split('/')[-1] if chosen_row.content_type else None
|
||||||
if chosen_row.content_type:
|
converted_meta_map.setdefault('low' if have_access else 'low_preview', file_hash)
|
||||||
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 = ['low', 'high', 'low_preview'] if have_access else ['low_preview', 'low', 'high']
|
||||||
preference = ['original'] if have_access else []
|
|
||||||
else:
|
|
||||||
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)
|
||||||
if not hash_value:
|
if not hash_value:
|
||||||
|
|
@ -300,17 +239,11 @@ 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
|
||||||
filename = stored.filename or ''
|
opts['content_ext'] = stored.filename.split('.')[-1]
|
||||||
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 = encrypted_json
|
content_meta = content['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:
|
||||||
|
|
@ -357,14 +290,9 @@ 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()
|
required_kinds = {'decrypted_low', 'decrypted_high'}
|
||||||
if content_kind == 'binary':
|
if ec_v3 and ec_v3.content_type.startswith('video/'):
|
||||||
if derivative_latest.get('decrypted_original') or converted_meta_map.get('original'):
|
required_kinds.add('decrypted_preview')
|
||||||
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}
|
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'
|
||||||
|
|
@ -390,15 +318,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,
|
'updated_at': upload_row.updated_at.isoformat() + 'Z' if upload_row.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
upload_state = upload_row.state if upload_row else None
|
final_state = 'ready' if display_options['content_url'] else None
|
||||||
if conversion_state == 'failed' or upload_state in ('failed', 'conversion_failed'):
|
if final_state != 'ready':
|
||||||
final_state = 'failed'
|
upload_state = upload_row.state if upload_row else None
|
||||||
elif conversion_state == 'ready':
|
if conversion_state == 'failed' or upload_state in ('failed', 'conversion_failed'):
|
||||||
final_state = 'ready'
|
final_state = 'failed'
|
||||||
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:
|
||||||
final_state = 'uploaded'
|
final_state = 'uploaded'
|
||||||
|
|
||||||
conversion_info = {
|
conversion_info = {
|
||||||
'state': conversion_state,
|
'state': conversion_state,
|
||||||
|
|
@ -413,10 +341,7 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,9 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ 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
|
||||||
|
|
@ -71,40 +70,9 @@ 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"
|
||||||
detected_content_type = None
|
preview_enabled = content_type.startswith("audio/") or content_type.startswith("video/")
|
||||||
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)
|
||||||
|
|
@ -188,7 +156,6 @@ 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,
|
||||||
|
|
@ -230,9 +197,7 @@ 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",
|
||||||
|
|
@ -254,14 +219,12 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -58,12 +58,9 @@ 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'
|
||||||
|
|
|
||||||
|
|
@ -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, and_, or_
|
from sqlalchemy import select
|
||||||
|
|
||||||
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,45 +196,17 @@ 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 existing:
|
if not 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={
|
meta={'encrypted_cid': ec.encrypted_cid, 'kind': 'original'},
|
||||||
'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',
|
||||||
|
|
@ -369,17 +341,10 @@ 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:
|
||||||
# Include preview-enabled media and non-media content that need decrypted originals
|
# Find A/V contents with preview_enabled and no ready low/low_preview derivatives yet
|
||||||
non_media_filter = and_(
|
ecs = (await session.execute(select(EncryptedContent).where(
|
||||||
EncryptedContent.content_type.isnot(None),
|
EncryptedContent.preview_enabled == True
|
||||||
~EncryptedContent.content_type.like('audio/%'),
|
).order_by(EncryptedContent.created_at.desc()))).scalars().all()
|
||||||
~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:
|
||||||
|
|
@ -400,12 +365,7 @@ 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'}
|
||||||
if ec.content_type.startswith('audio/'):
|
required = {'decrypted_low', 'decrypted_high'} if ec.content_type.startswith('audio/') else {'decrypted_low', 'decrypted_high', 'decrypted_preview'}
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -87,14 +87,11 @@ 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=formatted_title,
|
content_title=content_metadata.get('name', 'Unknown'),
|
||||||
),
|
),
|
||||||
message_type='notification',
|
message_type='notification',
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,6 @@ 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 = [],
|
||||||
|
|
@ -129,15 +128,6 @@ 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):
|
||||||
|
|
@ -152,21 +142,17 @@ 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': display_name,
|
'name': cleaned_title,
|
||||||
'title': cleaned_title,
|
'attributes': [
|
||||||
'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])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
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
|
||||||
|
|
@ -25,6 +26,7 @@ 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}. "
|
||||||
|
|
@ -112,6 +114,9 @@ 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:
|
||||||
|
|
@ -119,24 +124,18 @@ class PlayerTemplates:
|
||||||
if description:
|
if description:
|
||||||
description_block = f"{description}\n"
|
description_block = f"{description}\n"
|
||||||
|
|
||||||
metadata_title = content_metadata_json.get('title') or content_metadata_json.get('name')
|
title = (
|
||||||
if not metadata_title:
|
content_metadata_json.get('name')
|
||||||
metadata_title = (
|
or (encrypted_content_row.title if encrypted_content_row and encrypted_content_row.title else None)
|
||||||
(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}Этот контент был загружен в MY
|
{description_block}{status_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>"""
|
||||||
|
|
||||||
|
|
@ -153,7 +152,16 @@ class PlayerTemplates:
|
||||||
)
|
)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
|
|
||||||
if local_content and processing_messages:
|
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:
|
||||||
for msg in processing_messages:
|
for msg in processing_messages:
|
||||||
await self.delete_message(msg.message_id)
|
await self.delete_message(msg.message_id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from sqlalchemy import Column, Integer, BigInteger, String, ForeignKey, DateTime, Boolean, Float
|
from sqlalchemy import Column, Integer, 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(BigInteger, nullable=True)
|
telegram_id = Column(Integer, 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)
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,3 @@ 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
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue