Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -7,9 +7,9 @@ import gradio as gr
|
|
| 7 |
from b2sdk.v2 import *
|
| 8 |
# Newer b2sdk versions no longer export the exceptions through "import *": import it by name
|
| 9 |
try:
|
| 10 |
-
from b2sdk.v2.exception import NonExistentBucket
|
| 11 |
except ImportError:
|
| 12 |
-
from b2sdk.exception import NonExistentBucket
|
| 13 |
import dropbox
|
| 14 |
import gspread
|
| 15 |
import gspread.exceptions
|
|
@@ -143,6 +143,15 @@ STRINGS = {
|
|
| 143 |
"{p} MP4 (≤ 100 Mo) *", "{p} MP4 (≤ 100MB) *", "{p} MP4 (≤ 100MB) *"),
|
| 144 |
|
| 145 |
# ---- upload button + result ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
"upload_ready": ("Upload", "Carregar", "Uploaden", "Importer", "Carica", "Subir"),
|
| 147 |
"upload_waiting": ("Waiting for files to finish uploading…", "À espera que os ficheiros terminem de carregar…",
|
| 148 |
"Wachten tot de bestanden klaar zijn met uploaden…",
|
|
@@ -210,6 +219,18 @@ STRINGS = {
|
|
| 210 |
"⚠ Nom de l'auteur / Nom du jeu / Partie doivent contenir au moins un caractère valide.",
|
| 211 |
"⚠ Nome autore / Nome del gioco / Parte devono contenere almeno un carattere valido.",
|
| 212 |
"⚠ Nombre del autor / Nombre del juego / Parte deben contener al menos un carácter válido."),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
"m_no_bucket": ("⚠ No B2 bucket named '{b}' was found. Please create it (with Non-Shorts and Shorts subfolders) before uploading.",
|
| 214 |
"⚠ Não foi encontrado nenhum bucket B2 chamado '{b}'. Cria-o (com as subpastas Non-Shorts e Shorts) antes de fazer upload.",
|
| 215 |
"⚠ Er is geen B2-bucket met de naam '{b}' gevonden. Maak hem aan (met de submappen Non-Shorts en Shorts) voordat je uploadt.",
|
|
@@ -276,7 +297,8 @@ def find_missing_fields(fields: dict):
|
|
| 276 |
# ---------- FILENAME SANITIZATION ----------
|
| 277 |
# Characters that Windows (and B2/Dropbox in practice) don't allow in file/folder
|
| 278 |
# names: \ / : * ? " < > |
|
| 279 |
-
|
|
|
|
| 280 |
|
| 281 |
def strip_invalid_filename_chars(text):
|
| 282 |
"""Removes every character in INVALID_FILENAME_CHARS from text."""
|
|
@@ -295,6 +317,13 @@ def sanitize_author_component(author_name):
|
|
| 295 |
cleaned = re.sub(r"\s+", "_", cleaned)
|
| 296 |
return cleaned.upper()
|
| 297 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
def sanitize_game_part_component(game_name, part):
|
| 299 |
"""'Silent hill 2' + 'full game' -> 'Silent_Hill_2_Full_Game'"""
|
| 300 |
combined = strip_invalid_filename_chars(f"{game_name or ''} {part or ''}").strip()
|
|
@@ -310,6 +339,13 @@ def sanitize_filename_component(text):
|
|
| 310 |
return cleaned
|
| 311 |
|
| 312 |
# ---------- B2 BUCKET-PER-AUTHOR RESOLUTION ----------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
def get_author_bucket(safe_author):
|
| 314 |
"""Resolves the B2 bucket named exactly like the sanitized Author, or
|
| 315 |
returns None if no such bucket exists."""
|
|
@@ -317,12 +353,107 @@ def get_author_bucket(safe_author):
|
|
| 317 |
return b2_api.get_bucket_by_name(safe_author)
|
| 318 |
except NonExistentBucket:
|
| 319 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
|
| 321 |
# ---------- DUPLICATE-FILE CHECK HELPERS ----------
|
| 322 |
def b2_existing_filenames(bucket):
|
| 323 |
"""Returns the set of file names currently stored in a B2 bucket."""
|
| 324 |
return {file_version.file_name for file_version, _ in bucket.ls(recursive=True)}
|
| 325 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
def dropbox_file_exists(path):
|
| 327 |
"""Returns True if a file already exists at the given Dropbox path."""
|
| 328 |
try:
|
|
@@ -532,7 +663,7 @@ def write_long_video_row(author_name, game_name, part, yt_playlist, game_url, sy
|
|
| 532 |
ws = get_worksheet(target_tab)
|
| 533 |
row = find_target_row(ws)
|
| 534 |
ws.batch_update([
|
| 535 |
-
{"range": f"A{row}", "values": [[game_name]]},
|
| 536 |
{"range": f"B{row}", "values": [[part]]},
|
| 537 |
{"range": f"D{row}", "values": [[yt_playlist]]},
|
| 538 |
{"range": f"E{row}", "values": [[game_url]]},
|
|
@@ -548,7 +679,7 @@ def write_shorts_row(author_name, game_name, part, yt_playlist, game_url, synops
|
|
| 548 |
ws = get_worksheet(target_tab)
|
| 549 |
row = find_target_row(ws)
|
| 550 |
ws.batch_update([
|
| 551 |
-
{"range": f"A{row}", "values": [[game_name]]},
|
| 552 |
{"range": f"B{row}", "values": [[part]]},
|
| 553 |
{"range": f"C{row}", "values": [[yt_playlist]]},
|
| 554 |
{"range": f"D{row}", "values": [[game_url]]},
|
|
@@ -617,23 +748,43 @@ def upload_long(
|
|
| 617 |
if save_size > 50 * 1024 * 1024:
|
| 618 |
return T(lang, "m_save_size"), ""
|
| 619 |
|
| 620 |
-
# ---- Build the final filename:
|
| 621 |
safe_author = sanitize_author_component(author_name)
|
| 622 |
safe_game_part = sanitize_game_part_component(game_name, part)
|
| 623 |
-
if not safe_author or not safe_game_part:
|
| 624 |
return T(lang, "m_invalid_chars"), ""
|
| 625 |
|
| 626 |
# ---- Resolve the B2 bucket for this Author (one bucket per Author/channel) ----
|
| 627 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 628 |
if author_bucket is None:
|
| 629 |
return T(lang, "m_no_bucket", b=safe_author), ""
|
| 630 |
|
| 631 |
-
existing_files =
|
| 632 |
|
| 633 |
-
base_name =
|
| 634 |
mp4_name = f"Non-Shorts/{base_name}.mp4"
|
| 635 |
png_name = f"Non-Shorts/{base_name}.png"
|
| 636 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 637 |
uploaded = []
|
| 638 |
already_exists = []
|
| 639 |
errors = []
|
|
@@ -644,6 +795,7 @@ def upload_long(
|
|
| 644 |
try:
|
| 645 |
author_bucket.upload_local_file(mp4.name, mp4_name)
|
| 646 |
uploaded.append(f"MP4 ({mp4_name})")
|
|
|
|
| 647 |
except Exception as e:
|
| 648 |
if png_is_temp:
|
| 649 |
try:
|
|
@@ -658,6 +810,7 @@ def upload_long(
|
|
| 658 |
try:
|
| 659 |
author_bucket.upload_local_file(png_path, png_name, content_type="image/png")
|
| 660 |
uploaded.append(f"PNG ({png_name})")
|
|
|
|
| 661 |
except Exception as e:
|
| 662 |
errors.append(f"PNG ({png_name}) - {e}")
|
| 663 |
if png_is_temp:
|
|
@@ -665,6 +818,8 @@ def upload_long(
|
|
| 665 |
os.remove(png_path)
|
| 666 |
except OSError:
|
| 667 |
pass
|
|
|
|
|
|
|
| 668 |
|
| 669 |
share_url = ""
|
| 670 |
save_label = T(lang, "f_save")
|
|
@@ -731,25 +886,35 @@ def upload_shorts(author_name, insta, youtube, tiktok, game_name, part, yt_playl
|
|
| 731 |
if file_size > 100 * 1024 * 1024:
|
| 732 |
return T(lang, "m_short_size", n=name), ""
|
| 733 |
|
| 734 |
-
# ---- Build the final filenames:
|
| 735 |
safe_author = sanitize_author_component(author_name)
|
| 736 |
safe_game_part = sanitize_game_part_component(game_name, part)
|
| 737 |
-
if not safe_author or not safe_game_part:
|
| 738 |
return T(lang, "m_invalid_chars"), ""
|
| 739 |
|
| 740 |
# ---- Resolve the B2 bucket for this Author (one bucket per Author/channel) ----
|
| 741 |
-
|
|
|
|
|
|
|
|
|
|
| 742 |
if author_bucket is None:
|
| 743 |
return T(lang, "m_no_bucket", b=safe_author), ""
|
| 744 |
|
| 745 |
-
existing_files =
|
| 746 |
|
| 747 |
-
base_name =
|
| 748 |
remote_names = {
|
| 749 |
name: f"Shorts/{base_name}_{name}.mp4"
|
| 750 |
for name in files
|
| 751 |
}
|
| 752 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 753 |
uploaded = []
|
| 754 |
already_exists = []
|
| 755 |
errors = []
|
|
@@ -761,8 +926,11 @@ def upload_shorts(author_name, insta, youtube, tiktok, game_name, part, yt_playl
|
|
| 761 |
try:
|
| 762 |
author_bucket.upload_local_file(file.name, remote_name)
|
| 763 |
uploaded.append(f"{name} ({remote_name})")
|
|
|
|
| 764 |
except Exception as e:
|
| 765 |
errors.append(f"{name} ({remote_name}) - {e}")
|
|
|
|
|
|
|
| 766 |
|
| 767 |
message_parts = _summary(lang, uploaded, already_exists, errors)
|
| 768 |
try:
|
|
@@ -791,6 +959,7 @@ def router(
|
|
| 791 |
T(lang, "m_files_wait"),
|
| 792 |
"",
|
| 793 |
gr.update(visible=False),
|
|
|
|
| 794 |
)
|
| 795 |
|
| 796 |
if mode == "Long":
|
|
@@ -810,10 +979,20 @@ def router(
|
|
| 810 |
return (
|
| 811 |
message,
|
| 812 |
url,
|
| 813 |
-
gr.update(visible=bool(url))
|
|
|
|
| 814 |
)
|
| 815 |
|
| 816 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 817 |
# ============================================================
|
| 818 |
# THEME — dark editor look (same as the other apps)
|
| 819 |
# ============================================================
|
|
@@ -1169,7 +1348,8 @@ with gr.Blocks(**BLOCKS_KWARGS) as app:
|
|
| 1169 |
elem_id="upload_btn"
|
| 1170 |
)
|
| 1171 |
|
| 1172 |
-
|
|
|
|
| 1173 |
sec_result_md = gr.Markdown(T(L0, "sec_result"))
|
| 1174 |
output = gr.Textbox(
|
| 1175 |
label=T(L0, "status"),
|
|
@@ -1226,14 +1406,16 @@ with gr.Blocks(**BLOCKS_KWARGS) as app:
|
|
| 1226 |
return (
|
| 1227 |
gr.update(visible=True), # long
|
| 1228 |
gr.update(visible=False), # shorts
|
| 1229 |
-
gr.update(visible=False)
|
|
|
|
| 1230 |
)
|
| 1231 |
|
| 1232 |
else: # Shorts
|
| 1233 |
return (
|
| 1234 |
gr.update(visible=False),
|
| 1235 |
gr.update(visible=True),
|
| 1236 |
-
gr.update(visible=False)
|
|
|
|
| 1237 |
)
|
| 1238 |
|
| 1239 |
mode.change(
|
|
@@ -1242,7 +1424,8 @@ with gr.Blocks(**BLOCKS_KWARGS) as app:
|
|
| 1242 |
outputs=[
|
| 1243 |
long_col,
|
| 1244 |
shorts_col,
|
| 1245 |
-
share_col
|
|
|
|
| 1246 |
]
|
| 1247 |
).then(
|
| 1248 |
# Switching mode changes which files are actually required, so re-lock/
|
|
@@ -1297,6 +1480,7 @@ with gr.Blocks(**BLOCKS_KWARGS) as app:
|
|
| 1297 |
gr.update(visible=False), # shorts_col
|
| 1298 |
gr.update(visible=False), # share_col
|
| 1299 |
gr.update(interactive=False, value=T(lang, "upload_waiting")), # upload_btn
|
|
|
|
| 1300 |
)
|
| 1301 |
|
| 1302 |
refresh_all_btn.click(
|
|
@@ -1329,13 +1513,20 @@ with gr.Blocks(**BLOCKS_KWARGS) as app:
|
|
| 1329 |
shorts_col,
|
| 1330 |
share_col,
|
| 1331 |
upload_btn,
|
|
|
|
| 1332 |
],
|
| 1333 |
js="(lang) => { window.scrollTo({ top: 0, behavior: 'smooth' }); return lang; }",
|
| 1334 |
)
|
| 1335 |
|
| 1336 |
-
# Upload
|
| 1337 |
upload_btn.click(
|
| 1338 |
-
fn=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1339 |
inputs=[
|
| 1340 |
mode, author_name,
|
| 1341 |
mp4_long, png_long, save_long,
|
|
@@ -1348,8 +1539,14 @@ with gr.Blocks(**BLOCKS_KWARGS) as app:
|
|
| 1348 |
outputs=[
|
| 1349 |
output,
|
| 1350 |
share_url,
|
| 1351 |
-
share_col
|
|
|
|
| 1352 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1353 |
)
|
| 1354 |
|
| 1355 |
# ------------------------------------------------------------
|
|
|
|
| 7 |
from b2sdk.v2 import *
|
| 8 |
# Newer b2sdk versions no longer export the exceptions through "import *": import it by name
|
| 9 |
try:
|
| 10 |
+
from b2sdk.v2.exception import NonExistentBucket, RestrictedBucket
|
| 11 |
except ImportError:
|
| 12 |
+
from b2sdk.exception import NonExistentBucket, RestrictedBucket
|
| 13 |
import dropbox
|
| 14 |
import gspread
|
| 15 |
import gspread.exceptions
|
|
|
|
| 143 |
"{p} MP4 (≤ 100 Mo) *", "{p} MP4 (≤ 100MB) *", "{p} MP4 (≤ 100MB) *"),
|
| 144 |
|
| 145 |
# ---- upload button + result ----
|
| 146 |
+
"uploading": ("⏳ Uploading… please keep this page open", "⏳ A carregar… mantém esta página aberta",
|
| 147 |
+
"⏳ Bezig met uploaden… houd deze pagina open", "⏳ Import en cours… gardez cette page ouverte",
|
| 148 |
+
"⏳ Caricamento… tieni aperta questa pagina", "⏳ Subiendo… mantén esta página abierta"),
|
| 149 |
+
"m_storage_full": ("⚠ Your cloud storage is full. Please wait for your currently scheduled posts to be published, or contact support for assistance.",
|
| 150 |
+
"⚠ O teu armazenamento na cloud está cheio. Aguarda que as publicações agendadas sejam publicadas, ou contacta o suporte para obter ajuda.",
|
| 151 |
+
"⚠ Je cloudopslag is vol. Wacht tot je ingeplande berichten zijn gepubliceerd, of neem contact op met support voor hulp.",
|
| 152 |
+
"⚠ Votre stockage cloud est plein. Attendez que vos publications programmées soient publiées, ou contactez le support pour obtenir de l'aide.",
|
| 153 |
+
"⚠ Il tuo spazio cloud è pieno. Attendi che i post programmati vengano pubblicati, oppure contatta l'assistenza.",
|
| 154 |
+
"⚠ Tu almacenamiento en la nube está lleno. Espera a que se publiquen tus publicaciones programadas o contacta con soporte para obtener ayuda."),
|
| 155 |
"upload_ready": ("Upload", "Carregar", "Uploaden", "Importer", "Carica", "Subir"),
|
| 156 |
"upload_waiting": ("Waiting for files to finish uploading…", "À espera que os ficheiros terminem de carregar…",
|
| 157 |
"Wachten tot de bestanden klaar zijn met uploaden…",
|
|
|
|
| 219 |
"⚠ Nom de l'auteur / Nom du jeu / Partie doivent contenir au moins un caractère valide.",
|
| 220 |
"⚠ Nome autore / Nome del gioco / Parte devono contenere almeno un carattere valido.",
|
| 221 |
"⚠ Nombre del autor / Nombre del juego / Parte deben contener al menos un carácter válido."),
|
| 222 |
+
"m_key_restricted": ("⚠ The B2 application key set in this Space only opens the bucket '{a}', so it can't reach '{b}'. "
|
| 223 |
+
"Create a B2 key with access to all buckets and put it in B2_KEY_ID / B2_APP_KEY.",
|
| 224 |
+
"⚠ A chave de aplicação B2 configurada neste Space só abre o bucket '{a}', por isso não chega a '{b}'. "
|
| 225 |
+
"Cria uma chave B2 com acesso a todos os buckets e coloca-a em B2_KEY_ID / B2_APP_KEY.",
|
| 226 |
+
"⚠ De B2-applicatiesleutel van deze Space opent alleen bucket '{a}', dus '{b}' is niet bereikbaar. "
|
| 227 |
+
"Maak een B2-sleutel met toegang tot alle buckets en zet die in B2_KEY_ID / B2_APP_KEY.",
|
| 228 |
+
"⚠ La clé d'application B2 de ce Space n'ouvre que le bucket '{a}', elle ne peut donc pas atteindre '{b}'. "
|
| 229 |
+
"Créez une clé B2 avec accès à tous les buckets et mettez-la dans B2_KEY_ID / B2_APP_KEY.",
|
| 230 |
+
"⚠ La chiave applicativa B2 di questo Space apre solo il bucket '{a}', quindi non raggiunge '{b}'. "
|
| 231 |
+
"Crea una chiave B2 con accesso a tutti i bucket e inseriscila in B2_KEY_ID / B2_APP_KEY.",
|
| 232 |
+
"⚠ La clave de aplicación B2 de este Space solo abre el bucket '{a}', así que no llega a '{b}'. "
|
| 233 |
+
"Crea una clave B2 con acceso a todos los buckets y ponla en B2_KEY_ID / B2_APP_KEY."),
|
| 234 |
"m_no_bucket": ("⚠ No B2 bucket named '{b}' was found. Please create it (with Non-Shorts and Shorts subfolders) before uploading.",
|
| 235 |
"⚠ Não foi encontrado nenhum bucket B2 chamado '{b}'. Cria-o (com as subpastas Non-Shorts e Shorts) antes de fazer upload.",
|
| 236 |
"⚠ Er is geen B2-bucket met de naam '{b}' gevonden. Maak hem aan (met de submappen Non-Shorts en Shorts) voordat je uploadt.",
|
|
|
|
| 297 |
# ---------- FILENAME SANITIZATION ----------
|
| 298 |
# Characters that Windows (and B2/Dropbox in practice) don't allow in file/folder
|
| 299 |
# names: \ / : * ? " < > |
|
| 300 |
+
# also apostrophes (straight and curly) and backticks: "Assassin's" -> "Assassins"
|
| 301 |
+
INVALID_FILENAME_CHARS = '\\/:*?"<>|' + "'\u2018\u2019\u02bc`\u00b4"
|
| 302 |
|
| 303 |
def strip_invalid_filename_chars(text):
|
| 304 |
"""Removes every character in INVALID_FILENAME_CHARS from text."""
|
|
|
|
| 317 |
cleaned = re.sub(r"\s+", "_", cleaned)
|
| 318 |
return cleaned.upper()
|
| 319 |
|
| 320 |
+
def clean_game_name(game_name):
|
| 321 |
+
"""The Game Name exactly as it is written to the Google Sheet AND used in the file names:
|
| 322 |
+
'Tomb Raider/ 4' -> 'Tomb_Raider_4' | "Pokémon's Adventure" -> 'Pokémons_Adventure'"""
|
| 323 |
+
words = strip_invalid_filename_chars(game_name or "").split()
|
| 324 |
+
return "_".join(w.capitalize() for w in words if w)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
def sanitize_game_part_component(game_name, part):
|
| 328 |
"""'Silent hill 2' + 'full game' -> 'Silent_Hill_2_Full_Game'"""
|
| 329 |
combined = strip_invalid_filename_chars(f"{game_name or ''} {part or ''}").strip()
|
|
|
|
| 339 |
return cleaned
|
| 340 |
|
| 341 |
# ---------- B2 BUCKET-PER-AUTHOR RESOLUTION ----------
|
| 342 |
+
class B2KeyRestricted(Exception):
|
| 343 |
+
"""The B2 application key in the Space settings only opens one bucket (not every Author's)."""
|
| 344 |
+
def __init__(self, allowed):
|
| 345 |
+
super().__init__(allowed)
|
| 346 |
+
self.allowed = allowed
|
| 347 |
+
|
| 348 |
+
|
| 349 |
def get_author_bucket(safe_author):
|
| 350 |
"""Resolves the B2 bucket named exactly like the sanitized Author, or
|
| 351 |
returns None if no such bucket exists."""
|
|
|
|
| 353 |
return b2_api.get_bucket_by_name(safe_author)
|
| 354 |
except NonExistentBucket:
|
| 355 |
return None
|
| 356 |
+
except RestrictedBucket as e:
|
| 357 |
+
allowed = getattr(e, "bucket_name", None) or str(e).rsplit(":", 1)[-1].strip()
|
| 358 |
+
print(f"B2: the application key only allows bucket {allowed!r}, not {safe_author!r}")
|
| 359 |
+
raise B2KeyRestricted(allowed)
|
| 360 |
|
| 361 |
# ---------- DUPLICATE-FILE CHECK HELPERS ----------
|
| 362 |
def b2_existing_filenames(bucket):
|
| 363 |
"""Returns the set of file names currently stored in a B2 bucket."""
|
| 364 |
return {file_version.file_name for file_version, _ in bucket.ls(recursive=True)}
|
| 365 |
|
| 366 |
+
|
| 367 |
+
# ---------- CLOUD STORAGE LIMIT (STORAGE_LIMIT_GB of each world) ----------
|
| 368 |
+
# The limit lives in /home/Gamifypt/Universes/<WORLD>/.env on admin.gamify.pt, which this Space
|
| 369 |
+
# can't read, so it asks the admin panel (/api/storage-limit/<BUCKET>) with a short-lived token
|
| 370 |
+
# signed with GAMIFY_GATE_SECRET (the same secret the access gate already uses). What is in use
|
| 371 |
+
# is measured here, live: Non-Shorts/ + Shorts/ of the Author's bucket.
|
| 372 |
+
import base64
|
| 373 |
+
import hashlib
|
| 374 |
+
import hmac
|
| 375 |
+
import time
|
| 376 |
+
import requests
|
| 377 |
+
from urllib.parse import quote as _url_quote
|
| 378 |
+
|
| 379 |
+
def _admin_base_url():
|
| 380 |
+
"""The admin panel address. Taken from GAMIFY_LOGIN_URL (already set for the access gate,
|
| 381 |
+
e.g. admin.gamify.pt/home -> https://admin.gamify.pt); GAMIFY_ADMIN_URL overrides it if set."""
|
| 382 |
+
from urllib.parse import urlsplit
|
| 383 |
+
raw = (os.getenv("GAMIFY_ADMIN_URL") or os.getenv("GAMIFY_LOGIN_URL") or "https://admin.gamify.pt").strip()
|
| 384 |
+
if "://" not in raw:
|
| 385 |
+
raw = "https://" + raw
|
| 386 |
+
parts = urlsplit(raw)
|
| 387 |
+
return f"{parts.scheme}://{parts.netloc}"
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
GAMIFY_ADMIN_URL = _admin_base_url()
|
| 391 |
+
BYTES_PER_GB = 1024 ** 3 # same unit the admin panel uses for STORAGE_LIMIT_GB
|
| 392 |
+
STORAGE_FOLDERS = ("Non-Shorts/", "Shorts/")
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def b2_bucket_contents(bucket):
|
| 396 |
+
"""(set of file names, bytes used by Non-Shorts/ + Shorts/) — one listing of the bucket."""
|
| 397 |
+
names, used = set(), 0
|
| 398 |
+
for file_version, _ in bucket.ls(recursive=True):
|
| 399 |
+
names.add(file_version.file_name)
|
| 400 |
+
if file_version.file_name.startswith(STORAGE_FOLDERS):
|
| 401 |
+
used += int(getattr(file_version, "size", 0) or 0)
|
| 402 |
+
return names, used
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def _admin_call_token():
|
| 406 |
+
secret = os.getenv("GAMIFY_GATE_SECRET", "").strip()
|
| 407 |
+
if not secret:
|
| 408 |
+
return None
|
| 409 |
+
b64 = lambda raw: base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
| 410 |
+
payload = b64(json.dumps({"u": "upload-center", "exp": int(time.time()) + 120, "src": "home"}).encode())
|
| 411 |
+
sig = b64(hmac.new(secret.encode(), payload.encode(), hashlib.sha256).digest())
|
| 412 |
+
return f"{payload}.{sig}"
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def storage_limit_bytes(bucket_name):
|
| 416 |
+
"""The world's STORAGE_LIMIT_GB in bytes, or None (no limit set, or the admin panel can't be
|
| 417 |
+
reached — then the upload is NOT blocked, and the reason goes to the logs)."""
|
| 418 |
+
token = _admin_call_token()
|
| 419 |
+
if not token:
|
| 420 |
+
print("STORAGE LIMIT: GAMIFY_GATE_SECRET is not set in this Space -- limit not checked")
|
| 421 |
+
return None
|
| 422 |
+
try:
|
| 423 |
+
r = requests.get(f"{GAMIFY_ADMIN_URL}/api/storage-limit/{_url_quote(bucket_name)}",
|
| 424 |
+
headers={"X-Gamify-Gate": token}, timeout=15)
|
| 425 |
+
data = r.json() if r.content else {}
|
| 426 |
+
if not r.ok:
|
| 427 |
+
print(f"STORAGE LIMIT: admin answered {r.status_code} for {bucket_name}: {data}")
|
| 428 |
+
return None
|
| 429 |
+
limit = data.get("limit_gb")
|
| 430 |
+
return float(limit) * BYTES_PER_GB if limit is not None else None
|
| 431 |
+
except Exception as e:
|
| 432 |
+
print(f"STORAGE LIMIT: could not ask the admin panel for {bucket_name}: {e!r}")
|
| 433 |
+
return None
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def report_storage_used(bucket_name, used_bytes):
|
| 437 |
+
"""After an upload: tells the admin panel how full the bucket is now (STORAGE_LIMIT_USED +
|
| 438 |
+
its 'almost full' alerts). Never fails the upload."""
|
| 439 |
+
token = _admin_call_token()
|
| 440 |
+
if not token:
|
| 441 |
+
return
|
| 442 |
+
try:
|
| 443 |
+
requests.post(f"{GAMIFY_ADMIN_URL}/api/storage-limit/{_url_quote(bucket_name)}",
|
| 444 |
+
headers={"X-Gamify-Gate": token}, json={"used_bytes": int(used_bytes)}, timeout=15)
|
| 445 |
+
except Exception as e:
|
| 446 |
+
print(f"STORAGE LIMIT: could not report usage for {bucket_name}: {e!r}")
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
def storage_would_overflow(bucket_name, used_bytes, incoming_bytes):
|
| 450 |
+
limit = storage_limit_bytes(bucket_name)
|
| 451 |
+
if limit is None or incoming_bytes <= 0:
|
| 452 |
+
return False
|
| 453 |
+
print(f"STORAGE LIMIT: {bucket_name} uses {used_bytes / 2**20:.0f} MB of {limit / 2**20:.0f} MB, "
|
| 454 |
+
f"upload needs {incoming_bytes / 2**20:.0f} MB")
|
| 455 |
+
return used_bytes + incoming_bytes > limit
|
| 456 |
+
|
| 457 |
def dropbox_file_exists(path):
|
| 458 |
"""Returns True if a file already exists at the given Dropbox path."""
|
| 459 |
try:
|
|
|
|
| 663 |
ws = get_worksheet(target_tab)
|
| 664 |
row = find_target_row(ws)
|
| 665 |
ws.batch_update([
|
| 666 |
+
{"range": f"A{row}", "values": [[clean_game_name(game_name)]]},
|
| 667 |
{"range": f"B{row}", "values": [[part]]},
|
| 668 |
{"range": f"D{row}", "values": [[yt_playlist]]},
|
| 669 |
{"range": f"E{row}", "values": [[game_url]]},
|
|
|
|
| 679 |
ws = get_worksheet(target_tab)
|
| 680 |
row = find_target_row(ws)
|
| 681 |
ws.batch_update([
|
| 682 |
+
{"range": f"A{row}", "values": [[clean_game_name(game_name)]]},
|
| 683 |
{"range": f"B{row}", "values": [[part]]},
|
| 684 |
{"range": f"C{row}", "values": [[yt_playlist]]},
|
| 685 |
{"range": f"D{row}", "values": [[game_url]]},
|
|
|
|
| 748 |
if save_size > 50 * 1024 * 1024:
|
| 749 |
return T(lang, "m_save_size"), ""
|
| 750 |
|
| 751 |
+
# ---- Build the final filename: Game_Part.ext (inside the Author's bucket) ----
|
| 752 |
safe_author = sanitize_author_component(author_name)
|
| 753 |
safe_game_part = sanitize_game_part_component(game_name, part)
|
| 754 |
+
if not safe_author or not safe_game_part or not clean_game_name(game_name):
|
| 755 |
return T(lang, "m_invalid_chars"), ""
|
| 756 |
|
| 757 |
# ---- Resolve the B2 bucket for this Author (one bucket per Author/channel) ----
|
| 758 |
+
try:
|
| 759 |
+
author_bucket = get_author_bucket(safe_author)
|
| 760 |
+
except B2KeyRestricted as e:
|
| 761 |
+
if png_is_temp:
|
| 762 |
+
try:
|
| 763 |
+
os.remove(png_path)
|
| 764 |
+
except OSError:
|
| 765 |
+
pass
|
| 766 |
+
return T(lang, "m_key_restricted", a=e.allowed, b=safe_author), ""
|
| 767 |
if author_bucket is None:
|
| 768 |
return T(lang, "m_no_bucket", b=safe_author), ""
|
| 769 |
|
| 770 |
+
existing_files, used_bytes = b2_bucket_contents(author_bucket)
|
| 771 |
|
| 772 |
+
base_name = safe_game_part # file names: Game_Part only (the bucket is already the author's)
|
| 773 |
mp4_name = f"Non-Shorts/{base_name}.mp4"
|
| 774 |
png_name = f"Non-Shorts/{base_name}.png"
|
| 775 |
|
| 776 |
+
# ---- Cloud storage limit: what is already there + what this upload adds ----
|
| 777 |
+
incoming = ((mp4_size if mp4_name not in existing_files else 0) +
|
| 778 |
+
(os.path.getsize(png_path) if png_name not in existing_files else 0))
|
| 779 |
+
if storage_would_overflow(safe_author, used_bytes, incoming):
|
| 780 |
+
if png_is_temp:
|
| 781 |
+
try:
|
| 782 |
+
os.remove(png_path)
|
| 783 |
+
except OSError:
|
| 784 |
+
pass
|
| 785 |
+
return T(lang, "m_storage_full"), ""
|
| 786 |
+
added_bytes = 0
|
| 787 |
+
|
| 788 |
uploaded = []
|
| 789 |
already_exists = []
|
| 790 |
errors = []
|
|
|
|
| 795 |
try:
|
| 796 |
author_bucket.upload_local_file(mp4.name, mp4_name)
|
| 797 |
uploaded.append(f"MP4 ({mp4_name})")
|
| 798 |
+
added_bytes += mp4_size
|
| 799 |
except Exception as e:
|
| 800 |
if png_is_temp:
|
| 801 |
try:
|
|
|
|
| 810 |
try:
|
| 811 |
author_bucket.upload_local_file(png_path, png_name, content_type="image/png")
|
| 812 |
uploaded.append(f"PNG ({png_name})")
|
| 813 |
+
added_bytes += os.path.getsize(png_path)
|
| 814 |
except Exception as e:
|
| 815 |
errors.append(f"PNG ({png_name}) - {e}")
|
| 816 |
if png_is_temp:
|
|
|
|
| 818 |
os.remove(png_path)
|
| 819 |
except OSError:
|
| 820 |
pass
|
| 821 |
+
if added_bytes:
|
| 822 |
+
report_storage_used(safe_author, used_bytes + added_bytes)
|
| 823 |
|
| 824 |
share_url = ""
|
| 825 |
save_label = T(lang, "f_save")
|
|
|
|
| 886 |
if file_size > 100 * 1024 * 1024:
|
| 887 |
return T(lang, "m_short_size", n=name), ""
|
| 888 |
|
| 889 |
+
# ---- Build the final filenames: Game_Part_<PLATFORM>.mp4 (inside the Author's bucket) ----
|
| 890 |
safe_author = sanitize_author_component(author_name)
|
| 891 |
safe_game_part = sanitize_game_part_component(game_name, part)
|
| 892 |
+
if not safe_author or not safe_game_part or not clean_game_name(game_name):
|
| 893 |
return T(lang, "m_invalid_chars"), ""
|
| 894 |
|
| 895 |
# ---- Resolve the B2 bucket for this Author (one bucket per Author/channel) ----
|
| 896 |
+
try:
|
| 897 |
+
author_bucket = get_author_bucket(safe_author)
|
| 898 |
+
except B2KeyRestricted as e:
|
| 899 |
+
return T(lang, "m_key_restricted", a=e.allowed, b=safe_author), ""
|
| 900 |
if author_bucket is None:
|
| 901 |
return T(lang, "m_no_bucket", b=safe_author), ""
|
| 902 |
|
| 903 |
+
existing_files, used_bytes = b2_bucket_contents(author_bucket)
|
| 904 |
|
| 905 |
+
base_name = safe_game_part # file names: Game_Part only (the bucket is already the author's)
|
| 906 |
remote_names = {
|
| 907 |
name: f"Shorts/{base_name}_{name}.mp4"
|
| 908 |
for name in files
|
| 909 |
}
|
| 910 |
|
| 911 |
+
# ---- Cloud storage limit: what is already there + what this upload adds ----
|
| 912 |
+
incoming = sum(os.path.getsize(file.name) for name, file in files.items()
|
| 913 |
+
if remote_names[name] not in existing_files)
|
| 914 |
+
if storage_would_overflow(safe_author, used_bytes, incoming):
|
| 915 |
+
return T(lang, "m_storage_full"), ""
|
| 916 |
+
added_bytes = 0
|
| 917 |
+
|
| 918 |
uploaded = []
|
| 919 |
already_exists = []
|
| 920 |
errors = []
|
|
|
|
| 926 |
try:
|
| 927 |
author_bucket.upload_local_file(file.name, remote_name)
|
| 928 |
uploaded.append(f"{name} ({remote_name})")
|
| 929 |
+
added_bytes += os.path.getsize(file.name)
|
| 930 |
except Exception as e:
|
| 931 |
errors.append(f"{name} ({remote_name}) - {e}")
|
| 932 |
+
if added_bytes:
|
| 933 |
+
report_storage_used(safe_author, used_bytes + added_bytes)
|
| 934 |
|
| 935 |
message_parts = _summary(lang, uploaded, already_exists, errors)
|
| 936 |
try:
|
|
|
|
| 959 |
T(lang, "m_files_wait"),
|
| 960 |
"",
|
| 961 |
gr.update(visible=False),
|
| 962 |
+
gr.update(visible=True),
|
| 963 |
)
|
| 964 |
|
| 965 |
if mode == "Long":
|
|
|
|
| 979 |
return (
|
| 980 |
message,
|
| 981 |
url,
|
| 982 |
+
gr.update(visible=bool(url)),
|
| 983 |
+
gr.update(visible=True),
|
| 984 |
)
|
| 985 |
|
| 986 |
|
| 987 |
+
def router_safe(*args):
|
| 988 |
+
"""router, but an unexpected error still ends the upload with a message (and unlocks the page)."""
|
| 989 |
+
try:
|
| 990 |
+
return router(*args)
|
| 991 |
+
except Exception as e:
|
| 992 |
+
traceback.print_exc()
|
| 993 |
+
return (f"⚠ {type(e).__name__}: {e}", "", gr.update(visible=False), gr.update(visible=True))
|
| 994 |
+
|
| 995 |
+
|
| 996 |
# ============================================================
|
| 997 |
# THEME — dark editor look (same as the other apps)
|
| 998 |
# ============================================================
|
|
|
|
| 1348 |
elem_id="upload_btn"
|
| 1349 |
)
|
| 1350 |
|
| 1351 |
+
# Result: hidden until an upload finishes (success or a message to read)
|
| 1352 |
+
with gr.Group(elem_classes=["section-card"], visible=False) as result_group:
|
| 1353 |
sec_result_md = gr.Markdown(T(L0, "sec_result"))
|
| 1354 |
output = gr.Textbox(
|
| 1355 |
label=T(L0, "status"),
|
|
|
|
| 1406 |
return (
|
| 1407 |
gr.update(visible=True), # long
|
| 1408 |
gr.update(visible=False), # shorts
|
| 1409 |
+
gr.update(visible=False), # share link (reset on mode change)
|
| 1410 |
+
gr.update(visible=False), # result
|
| 1411 |
)
|
| 1412 |
|
| 1413 |
else: # Shorts
|
| 1414 |
return (
|
| 1415 |
gr.update(visible=False),
|
| 1416 |
gr.update(visible=True),
|
| 1417 |
+
gr.update(visible=False),
|
| 1418 |
+
gr.update(visible=False),
|
| 1419 |
)
|
| 1420 |
|
| 1421 |
mode.change(
|
|
|
|
| 1424 |
outputs=[
|
| 1425 |
long_col,
|
| 1426 |
shorts_col,
|
| 1427 |
+
share_col,
|
| 1428 |
+
result_group,
|
| 1429 |
]
|
| 1430 |
).then(
|
| 1431 |
# Switching mode changes which files are actually required, so re-lock/
|
|
|
|
| 1480 |
gr.update(visible=False), # shorts_col
|
| 1481 |
gr.update(visible=False), # share_col
|
| 1482 |
gr.update(interactive=False, value=T(lang, "upload_waiting")), # upload_btn
|
| 1483 |
+
gr.update(visible=False), # result_group
|
| 1484 |
)
|
| 1485 |
|
| 1486 |
refresh_all_btn.click(
|
|
|
|
| 1513 |
shorts_col,
|
| 1514 |
share_col,
|
| 1515 |
upload_btn,
|
| 1516 |
+
result_group,
|
| 1517 |
],
|
| 1518 |
js="(lang) => { window.scrollTo({ top: 0, behavior: 'smooth' }); return lang; }",
|
| 1519 |
)
|
| 1520 |
|
| 1521 |
+
# Upload: hide the old result and lock the button while it uploads; result appears at the end
|
| 1522 |
upload_btn.click(
|
| 1523 |
+
fn=lambda lang: (gr.update(visible=False),
|
| 1524 |
+
gr.update(interactive=False, value=T(lang if lang in LANG_CODES else DEFAULT_LANG, "uploading"))),
|
| 1525 |
+
inputs=[lang_dd],
|
| 1526 |
+
outputs=[result_group, upload_btn],
|
| 1527 |
+
show_progress="hidden",
|
| 1528 |
+
).then(
|
| 1529 |
+
fn=router_safe,
|
| 1530 |
inputs=[
|
| 1531 |
mode, author_name,
|
| 1532 |
mp4_long, png_long, save_long,
|
|
|
|
| 1539 |
outputs=[
|
| 1540 |
output,
|
| 1541 |
share_url,
|
| 1542 |
+
share_col,
|
| 1543 |
+
result_group,
|
| 1544 |
]
|
| 1545 |
+
).then(
|
| 1546 |
+
fn=refresh_upload_button,
|
| 1547 |
+
inputs=file_inputs,
|
| 1548 |
+
outputs=upload_btn,
|
| 1549 |
+
show_progress="hidden",
|
| 1550 |
)
|
| 1551 |
|
| 1552 |
# ------------------------------------------------------------
|