AdnaPlays commited on
Commit
e99da7d
·
verified ·
1 Parent(s): 7bb8b22

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +220 -23
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
- INVALID_FILENAME_CHARS = '\\/:*?"<>|'
 
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: Author_Game_Part.ext ----
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
- author_bucket = get_author_bucket(safe_author)
 
 
 
 
 
 
 
 
628
  if author_bucket is None:
629
  return T(lang, "m_no_bucket", b=safe_author), ""
630
 
631
- existing_files = b2_existing_filenames(author_bucket)
632
 
633
- base_name = f"{safe_author}_{safe_game_part}"
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: Author_Game_Part_<PLATFORM>.mp4 ----
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
- author_bucket = get_author_bucket(safe_author)
 
 
 
742
  if author_bucket is None:
743
  return T(lang, "m_no_bucket", b=safe_author), ""
744
 
745
- existing_files = b2_existing_filenames(author_bucket)
746
 
747
- base_name = f"{safe_author}_{safe_game_part}"
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
- with gr.Group(elem_classes=["section-card"]):
 
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) # share link (reset on mode change)
 
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=router,
 
 
 
 
 
 
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
  # ------------------------------------------------------------