AdnaPlays's picture
Update app.py
357dbea verified
Raw History Blame Contribute Delete
97.1 kB
import os
import re
import json
import inspect
import traceback
import gradio as gr
from b2sdk.v2 import *
# Newer b2sdk versions no longer export the exceptions through "import *": import it by name
try:
from b2sdk.v2.exception import NonExistentBucket, RestrictedBucket
except ImportError:
from b2sdk.exception import NonExistentBucket, RestrictedBucket
import dropbox
import gspread
import gspread.exceptions
from google.oauth2.service_account import Credentials
# ---------- LOAD .env ----------
try:
from dotenv import load_dotenv
load_dotenv()
print(".env file loaded")
except ImportError:
print("python-dotenv not installed")
print("B2_KEY_ID set:", bool(os.getenv("B2_KEY_ID")))
# ---------- LOAD CONFIG ----------
# One B2 bucket per Author/channel — resolved dynamically per upload, not fixed
# at startup. Only authenticate here; bucket resolution happens per-Author later.
B2_KEY_ID = os.getenv("B2_KEY_ID")
B2_APP_KEY = os.getenv("B2_APP_KEY")
info = InMemoryAccountInfo()
b2_api = B2Api(info)
b2_api.authorize_account("production", B2_KEY_ID, B2_APP_KEY)
# ---------- DROPBOX ----------
# A raw DROPBOX_ACCESS_TOKEN (from the App Console's "Generate access token"
# button) expires after a few hours - that's what was causing the intermittent
# failures. The refresh-token flow below lets the SDK silently mint new access
# tokens forever, so it never expires. Run get_dropbox_refresh_token.py once,
# locally, to obtain DROPBOX_APP_KEY / DROPBOX_APP_SECRET / DROPBOX_REFRESH_TOKEN.
DROPBOX_APP_KEY = os.getenv("DROPBOX_APP_KEY")
DROPBOX_APP_SECRET = os.getenv("DROPBOX_APP_SECRET")
DROPBOX_REFRESH_TOKEN = os.getenv("DROPBOX_REFRESH_TOKEN")
DROPBOX_TOKEN = os.getenv("DROPBOX_ACCESS_TOKEN") # legacy fallback only - expires in ~4h
if DROPBOX_REFRESH_TOKEN and DROPBOX_APP_KEY and DROPBOX_APP_SECRET:
dbx = dropbox.Dropbox(
oauth2_refresh_token=DROPBOX_REFRESH_TOKEN,
app_key=DROPBOX_APP_KEY,
app_secret=DROPBOX_APP_SECRET,
)
elif DROPBOX_TOKEN:
print(
"Warning: using a short-lived DROPBOX_ACCESS_TOKEN (expires in a few hours). "
"Set DROPBOX_APP_KEY / DROPBOX_APP_SECRET / DROPBOX_REFRESH_TOKEN instead "
"for a connection that doesn't expire - see get_dropbox_refresh_token.py."
)
dbx = dropbox.Dropbox(DROPBOX_TOKEN)
else:
raise RuntimeError(
"No Dropbox credentials configured. Set DROPBOX_REFRESH_TOKEN + "
"DROPBOX_APP_KEY + DROPBOX_APP_SECRET (recommended) or DROPBOX_ACCESS_TOKEN."
)
# ============================================================
# TRANSLATIONS (interface language dropdown)
# Every text is a tuple in this order: English, Português, Nederlands,
# Français, Italiano, Español. T(lang, key, **values) returns the text.
# Status messages keep "✓" / "⚠" at the start so the Status box colouring works
# in every language.
# ============================================================
LANG_CODES = ("en", "pt", "nl", "fr", "it", "es")
LANG_CHOICES = [("English", "en"), ("Português", "pt"), ("Nederlands", "nl"),
("Français", "fr"), ("Italiano", "it"), ("Español", "es")]
DEFAULT_LANG = "en"
STRINGS = {
# ---- top bar ----
"app_title": ("Upload Center", "Centro de Uploads", "Uploadcentrum", "Centre d'import",
"Centro di caricamento", "Centro de subidas"),
"app_sub": ("Non-Shorts videos and shorts to Cloud Storage",
"Vídeos longos e shorts para o Cloud Storage",
"Lange video's en shorts naar Cloud Storage",
"Vidéos longues et shorts vers Cloud Storage",
"Video lunghi e short su Cloud Storage",
"Vídeos largos y shorts a Cloud Storage"),
"reset": ("↺ Reset All", "↺ Repor tudo", "↺ Alles resetten",
"↺ Tout réinitialiser", "↺ Ripristina tutto", "↺ Restablecer todo"),
# ---- section 1 ----
"sec_author": ("### 1. Author & Upload Type", "### 1. Autor e tipo de upload", "### 1. Auteur & uploadtype",
"### 1. Auteur & type d'import", "### 1. Autore e tipo di caricamento",
"### 1. Autor y tipo de subida"),
"author_label": ('Author Name * (6–20 characters, exact match, e.g. ADNAPLAYS; \\ / : * ? " < > | are not allowed)',
'Nome do autor * (6–20 caracteres, igual ao canal, ex.: ADNAPLAYS; \\ / : * ? " < > | não são permitidos)',
'Auteursnaam * (6–20 tekens, exact gelijk, bijv. ADNAPLAYS; \\ / : * ? " < > | zijn niet toegestaan)',
'Nom de l\'auteur * (6 à 20 caractères, exactement, ex. : ADNAPLAYS ; \\ / : * ? " < > | sont interdits)',
'Nome autore * (6–20 caratteri, esatto, es. ADNAPLAYS; \\ / : * ? " < > | non sono ammessi)',
'Nombre del autor * (6–20 caracteres, exacto, p. ej. ADNAPLAYS; \\ / : * ? " < > | no están permitidos)'),
"m_author_length": ("⚠ Author Name must have between 6 and 20 characters (it has {n}).",
"⚠ O nome do autor tem de ter entre 6 e 20 caracteres (tem {n}).",
"⚠ De auteursnaam moet tussen 6 en 20 tekens hebben (nu {n}).",
"⚠ Le nom de l'auteur doit contenir entre 6 et 20 caractères (il en a {n}).",
"⚠ Il nome autore deve avere tra 6 e 20 caratteri (ne ha {n}).",
"⚠ El nombre del autor debe tener entre 6 y 20 caracteres (tiene {n})."),
"mode_label": ("Select Upload Type", "Escolhe o tipo de upload", "Kies het uploadtype",
"Choisissez le type d'import", "Scegli il tipo di caricamento", "Elige el tipo de subida"),
"mode_long": ("Non-Shorts", "Longo", "Lang", "Longue", "Lungo", "Largo"),
"mode_shorts": ("Shorts", "Shorts", "Shorts", "Shorts", "Short", "Shorts"),
# ---- section 2: long ----
"sec_long": ("### 2. Non-Shorts Video Upload", "### 2. Upload do vídeo longo", "### 2. Lange video uploaden",
"### 2. Import de la vidéo longue", "### 2. Caricamento video lungo",
"### 2. Subida del vídeo largo"),
"game_name": ("Game Name *", "Nome do jogo *", "Gamenaam *", "Nom du jeu *", "Nome del gioco *",
"Nombre del juego *"),
"part": ("Part *", "Parte *", "Deel *", "Partie *", "Parte *", "Parte *"),
"yt_playlist": ("YouTube Playlist URL *", "URL da playlist do YouTube *", "URL van de YouTube-afspeellijst *",
"URL de la playlist YouTube *", "URL della playlist YouTube *", "URL de la lista de YouTube *"),
"game_url": ("Game URL (Steam or any) *", "URL do jogo (Steam ou outro) *", "Game-URL (Steam of andere) *",
"URL du jeu (Steam ou autre) *", "URL del gioco (Steam o altro) *", "URL del juego (Steam u otro) *"),
"yt_synopsis": ("YouTube Synopsis", "Sinopse do YouTube", "YouTube-synopsis", "Synopsis YouTube",
"Sinossi YouTube", "Sinopsis de YouTube"),
"yt_chapters": ("YouTube Chapters", "Capítulos do YouTube", "YouTube-hoofdstukken", "Chapitres YouTube",
"Capitoli YouTube", "Capítulos de YouTube"),
"instructions": ("Video Instructions", "Instruções do vídeo", "Video-instructies", "Instructions vidéo",
"Istruzioni video", "Instrucciones del vídeo"),
"mp4_long": ("Non-Short MP4 (≤ 800MB) *", "MP4 longo (≤ 800MB) *", "Lange MP4 (≤ 800MB) *",
"MP4 longue (≤ 800 Mo) *", "MP4 lungo (≤ 800MB) *", "MP4 largo (≤ 800MB) *"),
"thumb": ("Thumbnail (any image — saved as PNG) *", "Miniatura (qualquer imagem — guardada em PNG) *",
"Thumbnail (elke afbeelding — opgeslagen als PNG) *", "Miniature (toute image — enregistrée en PNG) *",
"Miniatura (qualsiasi immagine — salvata in PNG) *", "Miniatura (cualquier imagen — guardada en PNG) *"),
"save_file": ("Game Save File (.ZIP or .RAR, optional)", "Ficheiro de save do jogo (.ZIP ou .RAR, opcional)",
"Savegame-bestand (.ZIP of .RAR, optioneel)", "Fichier de sauvegarde (.ZIP ou .RAR, facultatif)",
"File di salvataggio (.ZIP o .RAR, facoltativo)",
"Archivo de partida guardada (.ZIP o .RAR, opcional)"),
# ---- section 2: shorts ----
"sec_shorts": ("### 2. Shorts Upload", "### 2. Upload dos shorts", "### 2. Shorts uploaden",
"### 2. Import des shorts", "### 2. Caricamento short", "### 2. Subida de shorts"),
"video_synopsis": ("Video Synopsis", "Sinopse do vídeo", "Videosynopsis", "Synopsis de la vidéo",
"Sinossi del video", "Sinopsis del vídeo"),
"short_file": ("{p} MP4 (≤ 100MB, optional)", "{p} MP4 (≤ 100MB, opcional)", "{p} MP4 (≤ 100MB, optioneel)",
"{p} MP4 (≤ 100 Mo, facultatif)", "{p} MP4 (≤ 100MB, facoltativo)", "{p} MP4 (≤ 100MB, opcional)"),
"m_need_one_short": ("⚠ Add at least one short (INSTSTORIES, TIKTOK, YTBSTORIES or FACEBOOK).",
"⚠ Adiciona pelo menos um short (INSTSTORIES, TIKTOK, YTBSTORIES ou FACEBOOK).",
"⚠ Voeg minstens één short toe (INSTSTORIES, TIKTOK, YTBSTORIES of FACEBOOK).",
"⚠ Ajoutez au moins un short (INSTSTORIES, TIKTOK, YTBSTORIES ou FACEBOOK).",
"⚠ Aggiungi almeno uno short (INSTSTORIES, TIKTOK, YTBSTORIES o FACEBOOK).",
"⚠ Añade al menos un short (INSTSTORIES, TIKTOK, YTBSTORIES o FACEBOOK)."),
# ---- upload button + result ----
"uploading": ("⏳ Uploading… please keep this page open", "⏳ A carregar… mantém esta página aberta",
"⏳ Bezig met uploaden… houd deze pagina open", "⏳ Import en cours… gardez cette page ouverte",
"⏳ Caricamento… tieni aperta questa pagina", "⏳ Subiendo… mantén esta página abierta"),
"up_check": ("Checking the files and the cloud storage", "A verificar os ficheiros e o armazenamento",
"Bestanden en cloudopslag controleren", "Vérification des fichiers et du stockage",
"Verifica dei file e dello spazio cloud", "Comprobando los archivos y el almacenamiento"),
"up_file": ("Uploading {f}", "A carregar {f}", "{f} uploaden", "Import de {f}", "Caricamento di {f}", "Subiendo {f}"),
"up_save": ("Uploading the game save to Dropbox", "A carregar o save do jogo para o Dropbox",
"Savegame naar Dropbox uploaden", "Import de la sauvegarde vers Dropbox",
"Caricamento del salvataggio su Dropbox", "Subiendo la partida guardada a Dropbox"),
"up_sheet": ("Updating the Google Sheet", "A atualizar o Google Sheet", "Google Sheet bijwerken",
"Mise à jour du Google Sheet", "Aggiornamento del Google Sheet", "Actualizando el Google Sheet"),
"up_meta": ("{d} of {t} · {e} elapsed", "{d} de {t} · {e} decorridos", "{d} van {t} · {e} verstreken",
"{d} sur {t} · {e} écoulées", "{d} di {t} · {e} trascorsi", "{d} de {t} · {e} transcurridos"),
"up_elapsed": ("{e} elapsed", "{e} decorridos", "{e} verstreken", "{e} écoulées", "{e} trascorsi", "{e} transcurridos"),
"up_hint": ("The result appears here by itself — no need to refresh.",
"O resultado aparece aqui sozinho — não é preciso atualizar.",
"Het resultaat verschijnt hier vanzelf — niet vernieuwen nodig.",
"Le résultat s'affiche ici tout seul — inutile d'actualiser.",
"Il risultato appare qui da solo — non serve aggiornare.",
"El resultado aparece aquí solo — no hace falta actualizar."),
"up_done": ("✓ Finished in {e}", "✓ Concluído em {e}", "✓ Klaar in {e}", "✓ Terminé en {e}",
"✓ Completato in {e}", "✓ Listo en {e}"),
"up_busy": ("⏳ An upload is already running — its result will appear here when it's done.",
"⏳ Já há um upload a decorrer — o resultado aparece aqui quando terminar.",
"⏳ Er loopt al een upload — het resultaat verschijnt hier zodra die klaar is.",
"⏳ Un import est déjà en cours — son résultat s'affichera ici à la fin.",
"⏳ È già in corso un caricamento — il risultato apparirà qui al termine.",
"⏳ Ya hay una subida en curso — el resultado aparecerá aquí cuando termine."),
"up_lost": ("⚠ This upload was interrupted (the server restarted). Check the cloud storage and upload again if needed.",
"⚠ Este upload foi interrompido (o servidor reiniciou). Verifica o armazenamento e volta a carregar se for preciso.",
"⚠ Deze upload is onderbroken (de server is herstart). Controleer de opslag en upload opnieuw als dat nodig is.",
"⚠ Cet import a été interrompu (le serveur a redémarré). Vérifiez le stockage et relancez l'import si besoin.",
"⚠ Questo caricamento è stato interrotto (il server si è riavviato). Controlla lo spazio cloud e ricarica se serve.",
"⚠ Esta subida se interrumpió (el servidor se reinició). Revisa el almacenamiento y vuelve a subir si hace falta."),
"m_storage_full": ("⚠ Your cloud storage is full. Please wait for your currently scheduled posts to be published, or contact support for assistance.",
"⚠ O teu armazenamento na cloud está cheio. Aguarda que as publicações agendadas sejam publicadas, ou contacta o suporte para obter ajuda.",
"⚠ Je cloudopslag is vol. Wacht tot je ingeplande berichten zijn gepubliceerd, of neem contact op met support voor hulp.",
"⚠ Votre stockage cloud est plein. Attendez que vos publications programmées soient publiées, ou contactez le support pour obtenir de l'aide.",
"⚠ Il tuo spazio cloud è pieno. Attendi che i post programmati vengano pubblicati, oppure contatta l'assistenza.",
"⚠ Tu almacenamiento en la nube está lleno. Espera a que se publiquen tus publicaciones programadas o contacta con soporte para obtener ayuda."),
"upload_ready": ("Upload", "Carregar", "Uploaden", "Importer", "Carica", "Subir"),
"upload_waiting": ("Waiting for files to finish uploading…", "À espera que os ficheiros terminem de carregar…",
"Wachten tot de bestanden klaar zijn met uploaden…",
"En attente de la fin de l'import des fichiers…",
"In attesa che i file finiscano di caricarsi…",
"Esperando a que terminen de subirse los archivos…"),
"sec_result": ("### 3. Result", "### 3. Resultado", "### 3. Resultaat", "### 3. Résultat",
"### 3. Risultato", "### 3. Resultado"),
"status": ("Status", "Estado", "Status", "Statut", "Stato", "Estado"),
"share_url": ("Shareable URL (game save on Dropbox)", "URL partilhável (save do jogo no Dropbox)",
"Deelbare URL (savegame op Dropbox)", "URL partageable (sauvegarde sur Dropbox)",
"URL condivisibile (salvataggio su Dropbox)", "URL para compartir (partida guardada en Dropbox)"),
"copy_link": ("Copy Link", "Copiar link", "Link kopiëren", "Copier le lien", "Copia link", "Copiar enlace"),
"copied": ("✓ Copied!", "✓ Copiado!", "✓ Gekopieerd!", "✓ Copié !", "✓ Copiato!", "✓ ¡Copiado!"),
"no_link": ("No link", "Sem link", "Geen link", "Aucun lien", "Nessun link", "Sin enlace"),
"copy_failed": ("⚠ Failed", "⚠ Falhou", "⚠ Mislukt", "⚠ Échec", "⚠ Non riuscito", "⚠ Error"),
# ---- short field names used inside messages ----
"f_author": ("Author Name", "Nome do autor", "Auteursnaam", "Nom de l'auteur", "Nome autore", "Nombre del autor"),
"f_game": ("Game Name", "Nome do jogo", "Gamenaam", "Nom du jeu", "Nome del gioco", "Nombre del juego"),
"f_part": ("Part", "Parte", "Deel", "Partie", "Parte", "Parte"),
"f_playlist": ("YouTube Playlist URL", "URL da playlist do YouTube", "URL van de YouTube-afspeellijst",
"URL de la playlist YouTube", "URL della playlist YouTube", "URL de la lista de YouTube"),
"f_game_url": ("Game URL", "URL do jogo", "Game-URL", "URL du jeu", "URL del gioco", "URL del juego"),
"f_save": ("Game Save File", "Ficheiro de save", "Savegame-bestand", "Fichier de sauvegarde",
"File di salvataggio", "Archivo de partida"),
"f_share_link": ("share link", "link de partilha", "deellink", "lien de partage", "link di condivisione",
"enlace para compartir"),
# ---- status messages ----
"m_missing": ("⚠ Missing required field(s): {f}", "⚠ Campo(s) obrigatório(s) em falta: {f}",
"⚠ Verplichte velden ontbreken: {f}", "⚠ Champ(s) obligatoire(s) manquant(s) : {f}",
"⚠ Campi obbligatori mancanti: {f}", "⚠ Faltan campos obligatorios: {f}"),
"m_not_ready": ("⚠ {n} doesn't look fully uploaded yet. Please wait a moment for the upload to finish and try again.",
"⚠ {n} ainda não parece ter carregado por completo. Espera um momento que o carregamento termine e tenta outra vez.",
"⚠ {n} lijkt nog niet volledig geüpload. Wacht even tot de upload klaar is en probeer het opnieuw.",
"⚠ {n} ne semble pas encore entièrement importé. Attendez la fin de l'import et réessayez.",
"⚠ {n} non sembra ancora caricato del tutto. Attendi che il caricamento finisca e riprova.",
"⚠ {n} aún no parece haberse subido por completo. Espera a que termine la subida e inténtalo de nuevo."),
"m_bad_image": ("⚠ The thumbnail could not be read as an image ({e}). Please use JPG, PNG, WEBP, GIF, BMP, TIFF or HEIC.",
"⚠ Não foi possível ler a miniatura como imagem ({e}). Usa JPG, PNG, WEBP, GIF, BMP, TIFF ou HEIC.",
"⚠ De thumbnail kon niet als afbeelding worden gelezen ({e}). Gebruik JPG, PNG, WEBP, GIF, BMP, TIFF of HEIC.",
"⚠ La miniature n'a pas pu être lue comme image ({e}). Utilisez JPG, PNG, WEBP, GIF, BMP, TIFF ou HEIC.",
"⚠ Impossibile leggere la miniatura come immagine ({e}). Usa JPG, PNG, WEBP, GIF, BMP, TIFF o HEIC.",
"⚠ No se pudo leer la miniatura como imagen ({e}). Usa JPG, PNG, WEBP, GIF, BMP, TIFF o HEIC."),
"m_mp4_size": ("⚠ MP4 must be 800MB or lower.", "⚠ O MP4 tem de ter 800MB ou menos.",
"⚠ De MP4 mag maximaal 800MB zijn.", "⚠ Le MP4 doit faire 800 Mo maximum.",
"⚠ L'MP4 deve essere al massimo 800MB.", "⚠ El MP4 debe pesar 800MB o menos."),
"m_save_ext": ("⚠ Game Save File must be a .ZIP or .RAR file.", "⚠ O ficheiro de save tem de ser .ZIP ou .RAR.",
"⚠ Het savegame-bestand moet een .ZIP- of .RAR-bestand zijn.",
"⚠ Le fichier de sauvegarde doit être un .ZIP ou .RAR.",
"⚠ Il file di salvataggio deve essere .ZIP o .RAR.",
"⚠ El archivo de partida debe ser .ZIP o .RAR."),
"m_save_size": ("⚠ Game Save File exceeds 50MB limit.", "⚠ O ficheiro de save ultrapassa o limite de 50MB.",
"⚠ Het savegame-bestand is groter dan de limiet van 50MB.",
"⚠ Le fichier de sauvegarde dépasse la limite de 50 Mo.",
"⚠ Il file di salvataggio supera il limite di 50MB.",
"⚠ El archivo de partida supera el límite de 50MB."),
"m_short_size": ("⚠ {n} exceeds 100MB limit", "⚠ {n} ultrapassa o limite de 100MB",
"⚠ {n} is groter dan de limiet van 100MB", "⚠ {n} dépasse la limite de 100 Mo",
"⚠ {n} supera il limite di 100MB", "⚠ {n} supera el límite de 100MB"),
"m_invalid_chars": ("⚠ Author Name / Game Name / Part must contain at least one valid character.",
"⚠ Nome do autor / Nome do jogo / Parte têm de ter pelo menos um carácter válido.",
"⚠ Auteursnaam / Gamenaam / Deel moeten minstens één geldig teken bevatten.",
"⚠ Nom de l'auteur / Nom du jeu / Partie doivent contenir au moins un caractère valide.",
"⚠ Nome autore / Nome del gioco / Parte devono contenere almeno un carattere valido.",
"⚠ Nombre del autor / Nombre del juego / Parte deben contener al menos un carácter válido."),
"m_key_restricted": ("⚠ The B2 application key set in this Space only opens the bucket '{a}', so it can't reach '{b}'. "
"Create a B2 key with access to all buckets and put it in B2_KEY_ID / B2_APP_KEY.",
"⚠ A chave de aplicação B2 configurada neste Space só abre o bucket '{a}', por isso não chega a '{b}'. "
"Cria uma chave B2 com acesso a todos os buckets e coloca-a em B2_KEY_ID / B2_APP_KEY.",
"⚠ De B2-applicatiesleutel van deze Space opent alleen bucket '{a}', dus '{b}' is niet bereikbaar. "
"Maak een B2-sleutel met toegang tot alle buckets en zet die in B2_KEY_ID / B2_APP_KEY.",
"⚠ La clé d'application B2 de ce Space n'ouvre que le bucket '{a}', elle ne peut donc pas atteindre '{b}'. "
"Créez une clé B2 avec accès à tous les buckets et mettez-la dans B2_KEY_ID / B2_APP_KEY.",
"⚠ La chiave applicativa B2 di questo Space apre solo il bucket '{a}', quindi non raggiunge '{b}'. "
"Crea una chiave B2 con accesso a tutti i bucket e inseriscila in B2_KEY_ID / B2_APP_KEY.",
"⚠ La clave de aplicación B2 de este Space solo abre el bucket '{a}', así que no llega a '{b}'. "
"Crea una clave B2 con acceso a todos los buckets y ponla en B2_KEY_ID / B2_APP_KEY."),
"m_no_bucket": ("⚠ No B2 bucket named '{b}' was found. Please create it (with Non-Shorts and Shorts subfolders) before uploading.",
"⚠ Não foi encontrado nenhum bucket B2 chamado '{b}'. Cria-o (com as subpastas Non-Shorts e Shorts) antes de fazer upload.",
"⚠ Er is geen B2-bucket met de naam '{b}' gevonden. Maak hem aan (met de submappen Non-Shorts en Shorts) voordat je uploadt.",
"⚠ Aucun bucket B2 nommé '{b}' n'a été trouvé. Créez-le (avec les sous-dossiers Non-Shorts et Shorts) avant d'importer.",
"⚠ Nessun bucket B2 chiamato '{b}' trovato. Crealo (con le sottocartelle Non-Shorts e Shorts) prima di caricare.",
"⚠ No se encontró ningún bucket B2 llamado '{b}'. Créalo (con las subcarpetas Non-Shorts y Shorts) antes de subir."),
"m_mp4_failed": ("⚠ MP4 upload to B2 failed: {e}\nNothing else was uploaded — please try again.",
"⚠ O upload do MP4 para o B2 falhou: {e}\nMais nada foi carregado — tenta outra vez.",
"⚠ Upload van de MP4 naar B2 mislukt: {e}\nEr is verder niets geüpload — probeer het opnieuw.",
"⚠ Échec de l'import du MP4 vers B2 : {e}\nRien d'autre n'a été importé — réessayez.",
"⚠ Caricamento dell'MP4 su B2 non riuscito: {e}\nNient'altro è stato caricato — riprova.",
"⚠ Falló la subida del MP4 a B2: {e}\nNo se subió nada más — inténtalo de nuevo."),
"m_uploaded": ("✓ Uploaded: {x}", "✓ Carregado: {x}", "✓ Geüpload: {x}", "✓ Importé : {x}",
"✓ Caricato: {x}", "✓ Subido: {x}"),
"m_exists": ("⚠ Already exists (not re-uploaded): {x}", "⚠ Já existe (não foi carregado de novo): {x}",
"⚠ Bestaat al (niet opnieuw geüpload): {x}", "⚠ Existe déjà (non réimporté) : {x}",
"⚠ Esiste già (non ricaricato): {x}", "⚠ Ya existe (no se volvió a subir): {x}"),
"m_errors": ("⚠ Errors: {x}", "⚠ Erros: {x}", "⚠ Fouten: {x}", "⚠ Erreurs : {x}", "⚠ Errori: {x}",
"⚠ Errores: {x}"),
"m_sheet_ok": ("✓ Google Sheet updated (tab '{t}', row {r})",
"✓ Google Sheet atualizado (separador '{t}', linha {r})",
"✓ Google Sheet bijgewerkt (tabblad '{t}', rij {r})",
"✓ Google Sheet mis à jour (onglet '{t}', ligne {r})",
"✓ Google Sheet aggiornato (scheda '{t}', riga {r})",
"✓ Google Sheet actualizado (pestaña '{t}', fila {r})"),
"m_sheet_fail": ("⚠ Google Sheet update failed: {e}", "⚠ A atualização do Google Sheet falhou: {e}",
"⚠ Bijwerken van Google Sheet mislukt: {e}", "⚠ Échec de la mise à jour du Google Sheet : {e}",
"⚠ Aggiornamento del Google Sheet non riuscito: {e}",
"⚠ Falló la actualización del Google Sheet: {e}"),
"m_files_wait": ("⚠ One or more required files haven't finished uploading yet. Please wait for the upload to complete and try again.",
"⚠ Um ou mais ficheiros obrigatórios ainda não terminaram de carregar. Espera que o carregamento termine e tenta outra vez.",
"⚠ Een of meer verplichte bestanden zijn nog niet klaar met uploaden. Wacht tot de upload klaar is en probeer het opnieuw.",
"⚠ Un ou plusieurs fichiers obligatoires n'ont pas fini de s'importer. Attendez la fin de l'import et réessayez.",
"⚠ Uno o più file obbligatori non hanno ancora finito di caricarsi. Attendi la fine del caricamento e riprova.",
"⚠ Uno o más archivos obligatorios aún no han terminado de subirse. Espera a que termine la subida e inténtalo de nuevo."),
"m_invalid_mode": ("⚠ Invalid mode", "⚠ Modo inválido", "⚠ Ongeldige modus", "⚠ Mode invalide",
"⚠ Modalità non valida", "⚠ Modo no válido"),
}
def _lang_index(lang):
return LANG_CODES.index(lang) if lang in LANG_CODES else 0
def T(lang, key, **values):
"""Text for `key` in the chosen interface language (falls back to English)."""
entry = STRINGS.get(key)
if entry is None:
return key
text = entry[_lang_index(lang)] or entry[0]
return text.format(**values) if values else text
# texts used by the browser (Copy Link button)
JS_TEXT_KEYS = ("copied", "no_link", "copy_failed", "copy_link")
JS_I18N = {code: {k: STRINGS[k][i] for k in JS_TEXT_KEYS} for i, code in enumerate(LANG_CODES)}
# ---------- VALIDATION HELPER ----------
def find_missing_fields(fields: dict):
"""fields: {label: value}. Returns list of labels whose value is empty/blank."""
return [label for label, value in fields.items() if not value or not str(value).strip()]
# ---------- FILENAME SANITIZATION ----------
# Characters that Windows (and B2/Dropbox in practice) don't allow in file/folder
# names: \ / : * ? " < > |
# also apostrophes (straight and curly) and backticks: "Assassin's" -> "Assassins"
INVALID_FILENAME_CHARS = '\\/:*?"<>|' + "'\u2018\u2019\u02bc`\u00b4"
def strip_invalid_filename_chars(text):
"""Removes every character in INVALID_FILENAME_CHARS from text."""
return "".join(ch for ch in (text or "") if ch not in INVALID_FILENAME_CHARS)
def sanitize_author_input(value):
"""Live-cleans the Author Name field as the user types: strips filesystem-
invalid characters and forces uppercase, so e.g. 'adna/plays' becomes
'ADNAPLAYS' immediately rather than being accepted with the slash."""
cleaned = strip_invalid_filename_chars(value or "").upper()[:AUTHOR_MAX_CHARS]
return gr.update(value=cleaned)
AUTHOR_MIN_CHARS = 6 # the world's name (= its B2 bucket): 6 to 20 characters
AUTHOR_MAX_CHARS = 20
def author_length_error(safe_author, lang):
"""The message to show when the cleaned Author / world name is too short or too long, else None."""
n = len(safe_author or "")
if AUTHOR_MIN_CHARS <= n <= AUTHOR_MAX_CHARS:
return None
return T(lang, "m_author_length", n=n)
def sanitize_author_component(author_name):
"""'AdnaPlays' -> 'ADNAPLAYS' | 'Adna Plays' -> 'ADNA_PLAYS'"""
cleaned = strip_invalid_filename_chars(author_name or "").strip()
cleaned = re.sub(r"\s+", "_", cleaned)
return cleaned.upper()
def clean_game_name(game_name):
"""The Game Name exactly as it is written to the Google Sheet AND used in the file names:
'Tomb Raider/ 4' -> 'Tomb_Raider_4' | "Pokémon's Adventure" -> 'Pokémons_Adventure'"""
words = strip_invalid_filename_chars(game_name or "").split()
return "_".join(w.capitalize() for w in words if w)
def sanitize_game_part_component(game_name, part):
"""'Silent hill 2' + 'full game' -> 'Silent_Hill_2_Full_Game'"""
combined = strip_invalid_filename_chars(f"{game_name or ''} {part or ''}").strip()
words = combined.split()
return "_".join(w.capitalize() for w in words if w)
def sanitize_filename_component(text):
"""Turns a free-text field (game name, part, etc.) into a safe filename
component: strips invalid filesystem characters and replaces whitespace
with underscores."""
cleaned = strip_invalid_filename_chars(text or "").strip()
cleaned = re.sub(r"\s+", "_", cleaned)
return cleaned
# ---------- B2 BUCKET-PER-AUTHOR RESOLUTION ----------
class B2KeyRestricted(Exception):
"""The B2 application key in the Space settings only opens one bucket (not every Author's)."""
def __init__(self, allowed):
super().__init__(allowed)
self.allowed = allowed
def get_author_bucket(safe_author):
"""Resolves the B2 bucket named exactly like the sanitized Author, or
returns None if no such bucket exists."""
try:
return b2_api.get_bucket_by_name(safe_author)
except NonExistentBucket:
return None
except RestrictedBucket as e:
allowed = getattr(e, "bucket_name", None) or str(e).rsplit(":", 1)[-1].strip()
print(f"B2: the application key only allows bucket {allowed!r}, not {safe_author!r}")
raise B2KeyRestricted(allowed)
# ---------- DUPLICATE-FILE CHECK HELPERS ----------
def b2_existing_filenames(bucket):
"""Returns the set of file names currently stored in a B2 bucket."""
return {file_version.file_name for file_version, _ in bucket.ls(recursive=True)}
# ---------- CLOUD STORAGE LIMIT (STORAGE_LIMIT_GB of each world) ----------
# The limit lives in /home/Gamifypt/Universes/<WORLD>/.env on admin.gamify.pt, which this Space
# can't read, so it asks the admin panel (/api/storage-limit/<BUCKET>) with a short-lived token
# signed with GAMIFY_GATE_SECRET (the same secret the access gate already uses). What is in use
# is measured here, live: Non-Shorts/ + Shorts/ of the Author's bucket.
import base64
import hashlib
import hmac
import time
import requests
from urllib.parse import quote as _url_quote
def _admin_base_url():
"""The admin panel address. Taken from GAMIFY_LOGIN_URL (already set for the access gate,
e.g. admin.gamify.pt/home -> https://admin.gamify.pt); GAMIFY_ADMIN_URL overrides it if set."""
from urllib.parse import urlsplit
raw = (os.getenv("GAMIFY_ADMIN_URL") or os.getenv("GAMIFY_LOGIN_URL") or "https://admin.gamify.pt").strip()
if "://" not in raw:
raw = "https://" + raw
parts = urlsplit(raw)
return f"{parts.scheme}://{parts.netloc}"
GAMIFY_ADMIN_URL = _admin_base_url()
BYTES_PER_GB = 1024 ** 3 # same unit the admin panel uses for STORAGE_LIMIT_GB
STORAGE_FOLDERS = ("Non-Shorts/", "Shorts/")
def b2_bucket_contents(bucket):
"""(set of file names, bytes used by Non-Shorts/ + Shorts/) — one listing of the bucket."""
names, used = set(), 0
for file_version, _ in bucket.ls(recursive=True):
names.add(file_version.file_name)
if file_version.file_name.startswith(STORAGE_FOLDERS):
used += int(getattr(file_version, "size", 0) or 0)
return names, used
def _admin_call_token():
secret = os.getenv("GAMIFY_GATE_SECRET", "").strip()
if not secret:
return None
b64 = lambda raw: base64.urlsafe_b64encode(raw).decode().rstrip("=")
payload = b64(json.dumps({"u": "upload-center", "exp": int(time.time()) + 120, "src": "home"}).encode())
sig = b64(hmac.new(secret.encode(), payload.encode(), hashlib.sha256).digest())
return f"{payload}.{sig}"
def storage_limit_bytes(bucket_name):
"""The world's STORAGE_LIMIT_GB in bytes, or None (no limit set, or the admin panel can't be
reached — then the upload is NOT blocked, and the reason goes to the logs)."""
token = _admin_call_token()
if not token:
print("STORAGE LIMIT: GAMIFY_GATE_SECRET is not set in this Space -- limit not checked")
return None
try:
r = requests.get(f"{GAMIFY_ADMIN_URL}/api/storage-limit/{_url_quote(bucket_name)}",
headers={"X-Gamify-Gate": token}, timeout=15)
data = r.json() if r.content else {}
if not r.ok:
print(f"STORAGE LIMIT: admin answered {r.status_code} for {bucket_name}: {data}")
return None
limit = data.get("limit_gb")
return float(limit) * BYTES_PER_GB if limit is not None else None
except Exception as e:
print(f"STORAGE LIMIT: could not ask the admin panel for {bucket_name}: {e!r}")
return None
def report_storage_used(bucket_name, used_bytes):
"""After an upload: tells the admin panel how full the bucket is now (STORAGE_LIMIT_USED +
its 'almost full' alerts). Never fails the upload."""
token = _admin_call_token()
if not token:
return
try:
requests.post(f"{GAMIFY_ADMIN_URL}/api/storage-limit/{_url_quote(bucket_name)}",
headers={"X-Gamify-Gate": token}, json={"used_bytes": int(used_bytes)}, timeout=15)
except Exception as e:
print(f"STORAGE LIMIT: could not report usage for {bucket_name}: {e!r}")
def storage_would_overflow(bucket_name, used_bytes, incoming_bytes):
limit = storage_limit_bytes(bucket_name)
if limit is None or incoming_bytes <= 0:
return False
print(f"STORAGE LIMIT: {bucket_name} uses {used_bytes / 2**20:.0f} MB of {limit / 2**20:.0f} MB, "
f"upload needs {incoming_bytes / 2**20:.0f} MB")
return used_bytes + incoming_bytes > limit
def dropbox_file_exists(path):
"""Returns True if a file already exists at the given Dropbox path."""
try:
dbx.files_get_metadata(path)
return True
except dropbox.exceptions.ApiError:
return False
except Exception:
return False
# ---------- UPLOAD-READINESS PROTECTION ----------
# Gradio's file widgets upload to the Space's server in the background as soon as a
# file is picked, *before* the "Upload" button is clicked. There's a window where the
# button is clickable but a file hasn't finished arriving on the server yet. These
# helpers keep the Upload button locked until every file required by the selected
# mode has actually finished uploading, and double-check on the backend too.
def _file_is_ready(f):
"""True only if f is a real, fully-written temp file on the server (not mid-upload)."""
if f is None:
return False
try:
return os.path.exists(f.name) and os.path.getsize(f.name) > 0
except Exception:
return False
def files_ready_for_mode(selected_mode, mp4, png, insta, tiktok, yt, fb=None):
if selected_mode == "Long":
# save_long is optional, so it doesn't gate the button
return _file_is_ready(mp4) and _file_is_ready(png)
elif selected_mode == "Shorts":
# shorts are optional: at least one, and every one that was picked has finished arriving
shorts = (insta, tiktok, yt, fb)
return any(_file_is_ready(f) for f in shorts) and all(f is None or _file_is_ready(f) for f in shorts)
return False
def refresh_upload_button(selected_mode, mp4, png, insta, tiktok, yt, fb=None, lang=DEFAULT_LANG):
ready = files_ready_for_mode(selected_mode, mp4, png, insta, tiktok, yt, fb)
if ready:
return gr.update(interactive=True, value=T(lang, "upload_ready"))
return gr.update(interactive=False, value=T(lang, "upload_waiting"))
# ---------- THUMBNAIL: ANY IMAGE -> PNG ----------
# The thumbnail can be any image the user has (JPG, WEBP, HEIC from an iPhone, GIF, BMP, TIFF...).
# It is always stored in the cloud as a real PNG. A file that is already a PNG goes up untouched.
import tempfile
from PIL import Image, ImageOps
try: # HEIC / HEIF (iPhone photos) and AVIF: optional, needs pillow-heif
import pillow_heif
pillow_heif.register_heif_opener()
if hasattr(pillow_heif, "register_avif_opener"):
pillow_heif.register_avif_opener()
except Exception:
pass
THUMB_FILE_TYPES = ["image", ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tif", ".tiff",
".heic", ".heif", ".avif", ".jfif", ".ico", ".svg"]
def thumbnail_as_png(path):
"""Returns (png_path, made_new_file). Raises ValueError when the file is not a readable image."""
if os.path.splitext(path)[1].lower() == ".svg":
try:
import cairosvg
except ImportError:
raise ValueError("SVG needs the 'cairosvg' package on the server")
out = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
cairosvg.svg2png(url=path, write_to=out)
return out, True
try:
with Image.open(path) as img:
if img.format == "PNG" and not getattr(img, "is_animated", False):
img.verify() # a real, complete PNG: upload the original as it is
return path, False
img.seek(0) # animated GIF / WEBP: the first frame
img = ImageOps.exif_transpose(img) # phone photos: keep them the right way up
has_alpha = img.mode in ("RGBA", "LA", "PA") or (img.mode == "P" and "transparency" in img.info)
img = img.convert("RGBA" if has_alpha else "RGB")
out = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
img.save(out, "PNG", optimize=True)
return out, True
except ValueError:
raise
except Exception as e:
raise ValueError(f"{type(e).__name__}: {e}")
# ---------- DROPBOX GAME SAVE UPLOAD ----------
def upload_game_save_file(save_file, dropbox_path):
"""
Uploads a .zip/.rar game save file to Dropbox at the given path.
The actual file upload and the "get a shareable link" step are kept separate
on purpose: an old/incompatible dropbox SDK version can throw odd errors
(e.g. "unexpected use of the catch-all tag 'other'") while parsing Dropbox's
response, even though the bytes already reached Dropbox fine. That kind of
error typically happens *after* the transfer completes, while the SDK is
deserializing the confirmation - so we don't trust the exception alone; we
check whether the file actually landed before deciding it really failed.
Returns (share_url, link_error). share_url is "" if a link couldn't be
obtained; link_error is None on full success, otherwise a short message.
Raises only if the file genuinely isn't on Dropbox afterward.
"""
try:
with open(save_file.name, "rb") as f:
dbx.files_upload(
f.read(),
dropbox_path,
mode=dropbox.files.WriteMode.overwrite
)
except Exception as e:
# Log the FULL traceback to the Space's console/Logs tab - the message we
# show the user is deliberately short, but this is the only way to see
# what Dropbox's API actually returned under the hood.
print(f"[Dropbox upload error] {type(e).__name__}: {e}\n{traceback.format_exc()}")
if not dropbox_file_exists(dropbox_path):
raise RuntimeError(f"{type(e).__name__}: {e} (see Space logs for full traceback)") from e
# else: the transfer actually succeeded despite the SDK error - continue on
try:
link = dbx.sharing_create_shared_link_with_settings(dropbox_path)
return link.url, None
except Exception:
pass # link may already exist, or SDK choked on the response - try the fallback
try:
links = dbx.sharing_list_shared_links(path=dropbox_path)
if links.links:
return links.links[0].url, None
return "", "No shareable link was returned by Dropbox."
except Exception as e:
return "", f"file uploaded, but couldn't fetch a shareable link ({e})"
# ---------- GOOGLE SHEETS CONFIG ----------
def _spreadsheet_id(raw):
"""Accepts the bare ID or the whole spreadsheet address (…/spreadsheets/d/<ID>/edit…)."""
raw = (raw or "").strip().strip('"').strip("'")
m = re.search(r"/spreadsheets/d/([A-Za-z0-9_-]+)", raw)
return m.group(1) if m else raw
# the Collector spreadsheet (same default as COLLECTOR_SHEET_ID on admin.gamify.pt)
SPREADSHEET_ID = _spreadsheet_id(os.getenv("SPREADSHEET_ID") or "1Aq9Oyij8xwUahIuz6AfMszvCi9HZNYFnSQQ4TBnGRuY")
# On Hugging Face Spaces there's no persistent local filesystem to point a file path
# at, and you shouldn't be committing the raw service_account.json into the repo
# anyway. Instead, paste the *contents* of the JSON key file into a Space secret
# named GSHEETS_CREDS_JSON (Settings -> Variables and secrets -> New secret).
# GSHEETS_CREDS_FILE is kept as a fallback for local/PythonAnywhere use only.
GSHEETS_CREDS_JSON = os.getenv("GSHEETS_CREDS_JSON")
GSHEETS_CREDS_FILE = os.getenv("GSHEETS_CREDS_FILE")
_gs_client = None
_gs_identity = {"email": ""} # the service account's e-mail (shown in error messages)
def get_gsheets_client():
global _gs_client
if _gs_client is None:
scopes = ["https://www.googleapis.com/auth/spreadsheets"]
if GSHEETS_CREDS_JSON:
try:
info = json.loads(GSHEETS_CREDS_JSON)
except json.JSONDecodeError as e:
raise RuntimeError(
"GSHEETS_CREDS_JSON secret is not valid JSON. Make sure you "
"pasted the ENTIRE contents of the service_account.json file "
f"(including the outer {{ }}). Parse error: {e}"
)
creds = Credentials.from_service_account_info(info, scopes=scopes)
_gs_identity["email"] = info.get("client_email", "")
elif GSHEETS_CREDS_FILE and os.path.exists(GSHEETS_CREDS_FILE):
creds = Credentials.from_service_account_file(GSHEETS_CREDS_FILE, scopes=scopes)
else:
raise RuntimeError(
"No Google service account credentials found. On Hugging Face "
"Spaces, add a secret named GSHEETS_CREDS_JSON containing the full "
"contents of your service_account.json file."
)
_gs_client = gspread.authorize(creds)
return _gs_client
def get_worksheet(tab_name):
if not SPREADSHEET_ID:
raise RuntimeError("SPREADSHEET_ID is not set in this Space (Settings -> Variables and secrets). "
"Use the ID of the Collector spreadsheet that holds the <WORLD>Non-Shorts / "
"<WORLD>Shorts tabs.")
client = get_gsheets_client()
who = _gs_identity["email"] or "the service account in GSHEETS_CREDS_JSON"
try:
sheet = client.open_by_key(SPREADSHEET_ID)
except gspread.exceptions.SpreadsheetNotFound:
raise RuntimeError(f"Google can't find the spreadsheet '{SPREADSHEET_ID}' for {who}. Check that "
f"SPREADSHEET_ID is the Collector spreadsheet's ID (the code between /d/ and /edit) "
f"and that the spreadsheet is shared with {who} as Editor.")
except gspread.exceptions.APIError as e:
status = getattr(getattr(e, "response", None), "status_code", None)
if status in (403, 404):
raise RuntimeError(f"Google refused access to the spreadsheet '{SPREADSHEET_ID}' ({status}). "
f"Share it with {who} as Editor, and check that SPREADSHEET_ID is right.")
raise
try:
return sheet.worksheet(tab_name)
except gspread.exceptions.WorksheetNotFound:
available = [ws.title for ws in sheet.worksheets()]
raise RuntimeError(
f"No tab named '{tab_name}' found in the spreadsheet. "
f"Available tabs: {', '.join(available) if available else '(none found)'}. "
f"Check that the tab name is exact (case-sensitive, no extra spaces)."
)
def find_target_row(worksheet, header_row=1):
"""
Scans column A from the bottom of existing data upward.
- If a row's column A is not empty, it's skipped (move up).
- The first row found with an empty column A is the target
(this also lets you reuse a blank row if one exists between filled rows).
- If no blank is found among existing rows, appends right after the last filled row.
"""
col_a = worksheet.col_values(1) # includes header row; stops at last non-empty cell
for idx in range(len(col_a) - 1, header_row - 1, -1): # 0-based, walking upward
if not col_a[idx].strip():
return idx + 1 # 1-indexed row number
return len(col_a) + 1 # no gap found -> append after the last filled row
# ---------- AUTHOR -> DESTINATION TAB LOOKUP ----------
def write_long_video_row(author_name, game_name, part, yt_playlist, game_url, synopsis, chapters, instructions):
safe_author = sanitize_author_component(author_name)
target_tab = f"{safe_author}Non-Shorts"
ws = get_worksheet(target_tab)
row = find_target_row(ws)
ws.batch_update([
{"range": f"A{row}", "values": [[clean_game_name(game_name)]]},
{"range": f"B{row}", "values": [[part]]},
{"range": f"D{row}", "values": [[yt_playlist]]},
{"range": f"E{row}", "values": [[game_url]]},
{"range": f"I{row}", "values": [[synopsis or ""]]},
{"range": f"AL{row}", "values": [[chapters or ""]]},
{"range": f"AJ{row}", "values": [[instructions or ""]]},
])
return row, target_tab
def write_shorts_row(author_name, game_name, part, yt_playlist, game_url, synopsis):
safe_author = sanitize_author_component(author_name)
target_tab = f"{safe_author}Shorts"
ws = get_worksheet(target_tab)
row = find_target_row(ws)
ws.batch_update([
{"range": f"A{row}", "values": [[clean_game_name(game_name)]]},
{"range": f"B{row}", "values": [[part]]},
{"range": f"C{row}", "values": [[yt_playlist]]},
{"range": f"D{row}", "values": [[game_url]]},
{"range": f"J{row}", "values": [[synopsis or ""]]},
])
return row, target_tab
# ---------- UPLOAD FUNCTIONS ----------
# Status messages: "✓" = done, "⚠" = needs attention (warnings are highlighted in the UI).
def _summary(lang, uploaded, already_exists, errors):
parts = []
if uploaded:
parts.append(T(lang, "m_uploaded", x=", ".join(uploaded)))
if already_exists:
parts.append(T(lang, "m_exists", x=", ".join(already_exists)))
if errors:
parts.append(T(lang, "m_errors", x=", ".join(errors)))
return parts
def upload_long(
author_name, mp4, png, save_file,
game_name, part, yt_playlist, game_url,
synopsis, chapters, instructions, lang=DEFAULT_LANG
):
missing = find_missing_fields({
T(lang, "f_author"): author_name,
T(lang, "f_game"): game_name,
T(lang, "f_part"): part,
T(lang, "f_playlist"): yt_playlist,
T(lang, "f_game_url"): game_url,
})
if mp4 is None:
missing.append("MP4")
if png is None:
missing.append("PNG")
if missing:
return T(lang, "m_missing", f=", ".join(missing)), ""
author_name = strip_invalid_filename_chars(author_name).strip().upper()
# ---- Defensive re-check: make sure the files actually finished uploading to
# ---- the server before we start pushing them to B2. The button should already
# ---- prevent this, but we never trust the frontend alone for something this costly.
for label, f in (("MP4", mp4), ("PNG", png)):
if not _file_is_ready(f):
return T(lang, "m_not_ready", n=label), ""
if save_file is not None and not _file_is_ready(save_file):
return T(lang, "m_not_ready", n=T(lang, "f_save")), ""
mp4_size = os.path.getsize(mp4.name)
if mp4_size > 800 * 1024 * 1024:
return T(lang, "m_mp4_size"), ""
# any image type -> PNG (before anything goes to the cloud, so a bad image stops here)
try:
png_path, png_is_temp = thumbnail_as_png(png.name)
except ValueError as e:
return T(lang, "m_bad_image", e=e), ""
if save_file is not None:
save_ext = os.path.splitext(save_file.name)[1].lower()
if save_ext not in (".zip", ".rar"):
return T(lang, "m_save_ext"), ""
save_size = os.path.getsize(save_file.name)
if save_size > 50 * 1024 * 1024:
return T(lang, "m_save_size"), ""
# ---- Build the final filename: Game_Part.ext (inside the Author's bucket) ----
safe_author = sanitize_author_component(author_name)
safe_game_part = sanitize_game_part_component(game_name, part)
if not safe_author or not safe_game_part or not clean_game_name(game_name):
return T(lang, "m_invalid_chars"), ""
if author_length_error(safe_author, lang):
return author_length_error(safe_author, lang), ""
# ---- Resolve the B2 bucket for this Author (one bucket per Author/channel) ----
try:
author_bucket = get_author_bucket(safe_author)
except B2KeyRestricted as e:
if png_is_temp:
try:
os.remove(png_path)
except OSError:
pass
return T(lang, "m_key_restricted", a=e.allowed, b=safe_author), ""
if author_bucket is None:
return T(lang, "m_no_bucket", b=safe_author), ""
existing_files, used_bytes = b2_bucket_contents(author_bucket)
base_name = safe_game_part # file names: Game_Part only (the bucket is already the author's)
mp4_name = f"Non-Shorts/{base_name}.mp4"
png_name = f"Non-Shorts/{base_name}.png"
# ---- Cloud storage limit: what is already there + what this upload adds ----
incoming = ((mp4_size if mp4_name not in existing_files else 0) +
(os.path.getsize(png_path) if png_name not in existing_files else 0))
if storage_would_overflow(safe_author, used_bytes, incoming):
if png_is_temp:
try:
os.remove(png_path)
except OSError:
pass
return T(lang, "m_storage_full"), ""
added_bytes = 0
_up_total(incoming)
uploaded = []
already_exists = []
errors = []
if mp4_name in existing_files:
already_exists.append(f"MP4 ({mp4_name})")
else:
try:
_up_stage("up_file", f="MP4")
author_bucket.upload_local_file(mp4.name, mp4_name, progress_listener=_up_listener())
_up_file_done(mp4_size)
uploaded.append(f"MP4 ({mp4_name})")
added_bytes += mp4_size
except Exception as e:
if png_is_temp:
try:
os.remove(png_path)
except OSError:
pass
return T(lang, "m_mp4_failed", e=e), ""
if png_name in existing_files:
already_exists.append(f"PNG ({png_name})")
else:
try:
_up_stage("up_file", f="PNG")
author_bucket.upload_local_file(png_path, png_name, content_type="image/png",
progress_listener=_up_listener())
_up_file_done(os.path.getsize(png_path))
uploaded.append(f"PNG ({png_name})")
added_bytes += os.path.getsize(png_path)
except Exception as e:
errors.append(f"PNG ({png_name}) - {e}")
if png_is_temp:
try:
os.remove(png_path)
except OSError:
pass
if added_bytes:
report_storage_used(safe_author, used_bytes + added_bytes)
share_url = ""
save_label = T(lang, "f_save")
if save_file is not None:
save_ext = os.path.splitext(save_file.name)[1].lower()
save_remote_name = f"{base_name}{save_ext}"
dropbox_path = f"/Game Saves/{save_remote_name}"
if dropbox_file_exists(dropbox_path):
already_exists.append(f"{save_label} ({save_remote_name})")
else:
try:
_up_stage("up_save")
share_url, link_error = upload_game_save_file(save_file, dropbox_path)
uploaded.append(f"{save_label} ({save_remote_name})")
if link_error:
errors.append(f"{save_label} {T(lang, 'f_share_link')} ({save_remote_name}) - {link_error}")
except Exception as e:
errors.append(f"{save_label} ({save_remote_name}) - {e}")
message_parts = _summary(lang, uploaded, already_exists, errors)
_up_stage("up_sheet")
try:
row, target_tab = write_long_video_row(
author_name, game_name, part, yt_playlist, game_url,
synopsis, chapters, instructions
)
message_parts.append(T(lang, "m_sheet_ok", t=target_tab, r=row))
except Exception as e:
message_parts.append(T(lang, "m_sheet_fail", e=e))
return "\n".join(message_parts), share_url
def upload_shorts(author_name, insta, youtube, tiktok, facebook, game_name, part, yt_playlist, game_url, synopsis,
lang=DEFAULT_LANG):
missing = find_missing_fields({
T(lang, "f_author"): author_name,
T(lang, "f_game"): game_name,
T(lang, "f_part"): part,
T(lang, "f_playlist"): yt_playlist,
T(lang, "f_game_url"): game_url,
})
files = {
"INSTSTORIES": insta,
"TIKTOK": tiktok,
"YTBSTORIES": youtube,
"FACEBOOK": facebook,
}
files = {name: file for name, file in files.items() if file is not None} # only the shorts picked
if missing:
return T(lang, "m_missing", f=", ".join(missing)), ""
if not files:
return T(lang, "m_need_one_short"), ""
author_name = strip_invalid_filename_chars(author_name).strip().upper()
# ---- Defensive re-check: same guard as upload_long, in case the button's
# ---- readiness state ever gets out of sync with reality.
for name, file in files.items():
if not _file_is_ready(file):
return T(lang, "m_not_ready", n=name), ""
for name, file in files.items():
file_size = os.path.getsize(file.name)
if file_size > 100 * 1024 * 1024:
return T(lang, "m_short_size", n=name), ""
# ---- Build the final filenames: Game_Part_<PLATFORM>.mp4 (inside the Author's bucket) ----
safe_author = sanitize_author_component(author_name)
safe_game_part = sanitize_game_part_component(game_name, part)
if not safe_author or not safe_game_part or not clean_game_name(game_name):
return T(lang, "m_invalid_chars"), ""
if author_length_error(safe_author, lang):
return author_length_error(safe_author, lang), ""
# ---- Resolve the B2 bucket for this Author (one bucket per Author/channel) ----
try:
author_bucket = get_author_bucket(safe_author)
except B2KeyRestricted as e:
return T(lang, "m_key_restricted", a=e.allowed, b=safe_author), ""
if author_bucket is None:
return T(lang, "m_no_bucket", b=safe_author), ""
existing_files, used_bytes = b2_bucket_contents(author_bucket)
base_name = safe_game_part # file names: Game_Part only (the bucket is already the author's)
remote_names = {
name: f"Shorts/{base_name}_{name}.mp4"
for name in files
}
# ---- Cloud storage limit: what is already there + what this upload adds ----
incoming = sum(os.path.getsize(file.name) for name, file in files.items()
if remote_names[name] not in existing_files)
if storage_would_overflow(safe_author, used_bytes, incoming):
return T(lang, "m_storage_full"), ""
added_bytes = 0
_up_total(incoming)
uploaded = []
already_exists = []
errors = []
for name, file in files.items():
remote_name = remote_names[name]
if remote_name in existing_files:
already_exists.append(f"{name} ({remote_name})")
else:
try:
_up_stage("up_file", f=name)
author_bucket.upload_local_file(file.name, remote_name, progress_listener=_up_listener())
_up_file_done(os.path.getsize(file.name))
uploaded.append(f"{name} ({remote_name})")
added_bytes += os.path.getsize(file.name)
except Exception as e:
errors.append(f"{name} ({remote_name}) - {e}")
if added_bytes:
report_storage_used(safe_author, used_bytes + added_bytes)
message_parts = _summary(lang, uploaded, already_exists, errors)
_up_stage("up_sheet")
try:
row, target_tab = write_shorts_row(author_name, game_name, part, yt_playlist, game_url, synopsis)
message_parts.append(T(lang, "m_sheet_ok", t=target_tab, r=row))
except Exception as e:
message_parts.append(T(lang, "m_sheet_fail", e=e))
return "\n".join(message_parts), ""
def router(
mode, author_name,
mp4, png, save_long,
game_name_long, part_long, yt_playlist_long, game_url_long,
synopsis_long, chapters_long, instructions_long,
insta, yt, tiktok, fb,
game_name_shorts, part_shorts, yt_playlist_shorts, game_url_shorts, synopsis_shorts,
lang=DEFAULT_LANG
):
lang = lang if lang in LANG_CODES else DEFAULT_LANG
# Final gate, right before doing any real work: even if the button was somehow
# clickable, refuse to proceed unless the files this mode needs are actually
# ready on the server.
if not files_ready_for_mode(mode, mp4, png, insta, tiktok, yt, fb):
return (
T(lang, "m_files_wait"),
"",
gr.update(visible=False),
gr.update(visible=True),
)
if mode == "Long":
message, url = upload_long(
author_name, mp4, png, save_long,
game_name_long, part_long, yt_playlist_long, game_url_long,
synopsis_long, chapters_long, instructions_long, lang
)
elif mode == "Shorts":
message, url = upload_shorts(
author_name, insta, yt, tiktok, fb,
game_name_shorts, part_shorts, yt_playlist_shorts, game_url_shorts, synopsis_shorts, lang
)
else:
message, url = (T(lang, "m_invalid_mode"), "")
return (
message,
url,
gr.update(visible=bool(url)),
gr.update(visible=True),
)
def router_safe(*args):
"""router, but an unexpected error still ends the upload with a message (and unlocks the page)."""
try:
return router(*args)
except Exception as e:
traceback.print_exc()
return (f"⚠ {type(e).__name__}: {e}", "", gr.update(visible=False), gr.update(visible=True))
# ============================================================
# BACKGROUND UPLOAD + PROGRESS BAR
# Sending an 800 MB video to the cloud takes minutes. Upload now only STARTS a job on the server
# and returns at once; a light check every 2 s shows the progress bar and, at the end, the result.
# If the page reloads (or the connection drops), the job keeps going and comes back on its own.
# ============================================================
import threading
import uuid
import html as html_lib
try:
from b2sdk.v2 import AbstractProgressListener as _B2ProgressListener
except Exception:
try:
from b2sdk.progress import AbstractProgressListener as _B2ProgressListener
except Exception:
_B2ProgressListener = None
UP_POLL_SECONDS = 2.0
UP_KEEP_HOURS = 24
UP_LOST_HOURS = 6
_UP_JOBS = {}
_UP_LOCK = threading.Lock()
_UP_LOCAL = threading.local()
_UP_ID_RE = re.compile(r"^[a-f0-9]{32}$")
def _up_job():
return getattr(_UP_LOCAL, "job", None)
def _up_stage(key, **values):
job = _up_job()
if job is not None:
job["stage"] = (key, values)
def _up_total(total_bytes):
job = _up_job()
if job is not None:
job["total_bytes"] = int(total_bytes or 0)
def _up_file_done(size):
job = _up_job()
if job is not None:
job["completed_bytes"] += int(size or 0)
job["done_bytes"] = job["completed_bytes"]
if _B2ProgressListener is not None:
class _UploadListener(_B2ProgressListener):
"""Tells the progress bar how many bytes of the current file reached the cloud."""
def __init__(self, job):
super().__init__()
self.job = job
self.base = job["completed_bytes"]
def set_total_bytes(self, total_byte_count):
pass
def bytes_completed(self, byte_count):
self.job["done_bytes"] = max(self.job["done_bytes"], self.base + int(byte_count or 0))
def close(self):
try:
super().close()
except Exception:
pass
def _up_listener():
job = _up_job()
if job is None or _B2ProgressListener is None:
return None
return _UploadListener(job)
def _fmt_elapsed(seconds):
seconds = int(max(seconds, 0))
m, s = divmod(seconds, 60)
h, m = divmod(m, 60)
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
def _fmt_size(n):
n = float(n or 0)
return f"{n / 2**30:.2f} GB" if n >= 2**30 else f"{n / 2**20:.0f} MB"
def _up_pct(job):
if job["state"] == "done":
return 100
key = (job.get("stage") or ("up_check", {}))[0]
if key == "up_check":
return 2
if key == "up_save":
return 90
if key == "up_sheet":
return 95
total = job.get("total_bytes") or 0
done = min(job.get("done_bytes") or 0, total) if total else 0
return int(5 + 85 * (done / total)) if total else 5
def upload_progress_html(job, lang):
"""The progress bar shown under the Upload button (empty when there is no job)."""
if not job:
return ""
esc = html_lib.escape
elapsed = (job.get("finished") or time.time()) - job["created"]
pct = _up_pct(job)
if job["state"] == "done":
title, meta, fill, hint = T(lang, "up_done", e=_fmt_elapsed(elapsed)), "", "#3DDC84", ""
else:
key, values = job.get("stage") or ("up_check", {})
title = T(lang, key, **values)
total = job.get("total_bytes") or 0
if key == "up_file" and total:
meta = T(lang, "up_meta", d=_fmt_size(min(job.get("done_bytes") or 0, total)), t=_fmt_size(total),
e=_fmt_elapsed(elapsed))
else:
meta = T(lang, "up_elapsed", e=_fmt_elapsed(elapsed))
fill = f"linear-gradient(90deg, {ACCENT}, {ACCENT_2})"
hint = f"<div style='margin-top:6px;font-size:12px;color:{MUTED}'>{esc(T(lang, 'up_hint'))}</div>"
return (
f"<div class='uc-job' style='background:{SURFACE};border:1px solid {BORDER};border-radius:12px;padding:12px 14px;margin:8px 0'>"
f"<div style='display:flex;align-items:baseline;justify-content:space-between;gap:12px;flex-wrap:wrap'>"
f"<div style='font-weight:700;color:{TEXT}'>{esc(title)}</div>"
f"<div style='font-size:22px;font-weight:800;color:{TEXT};font-variant-numeric:tabular-nums'>{pct}%</div></div>"
f"<div style='height:10px;border-radius:999px;background:{INPUT};overflow:hidden;margin-top:8px'>"
f"<div style='height:100%;width:{pct}%;background:{fill};border-radius:999px;transition:width 1.8s linear'></div></div>"
f"<div style='margin-top:6px;font-size:12px;color:{MUTED};font-variant-numeric:tabular-nums'>{esc(meta)}</div>"
f"{hint}</div>"
)
def _up_cleanup():
cutoff = time.time() - UP_KEEP_HOURS * 3600
with _UP_LOCK:
for jid in [j for j, job in _UP_JOBS.items() if job["state"] == "done" and job.get("finished", 0) < cutoff]:
_UP_JOBS.pop(jid, None)
def _run_upload_job(job, args):
_UP_LOCAL.job = job
try:
job["result"] = router_safe(*args)
finally:
job["state"] = "done"
job["finished"] = time.time()
_UP_LOCAL.job = None
print(f"UPLOAD JOB {job['id']} finished in {_fmt_elapsed(job['finished'] - job['created'])}")
def _up_from_box(value):
raw = str(value or "").strip()
jid, _, ts = raw.partition("|")
if not _UP_ID_RE.match(jid):
return None, "", None
try:
age = (time.time() - int(ts) / 1000) / 3600 if ts else None
except ValueError:
age = None
with _UP_LOCK:
return _UP_JOBS.get(jid), jid, age
def start_upload(*values):
"""Upload: starts the job and returns at once.
Outputs: job_box, job_timer, upload_btn, progress_html, output, share_url, share_col, result_group."""
*args, current = values
lang = args[-1] if args and args[-1] in LANG_CODES else DEFAULT_LANG
keep = gr.update()
running, _, _ = _up_from_box(current)
if running is not None and running["state"] == "running":
return (keep, gr.update(active=True), gr.update(interactive=False, value=T(lang, "uploading")),
upload_progress_html(running, lang), T(lang, "up_busy"), keep, keep, gr.update(visible=True))
mode, mp4, png, insta, yt, tiktok, fb = args[0], args[2], args[3], args[12], args[13], args[14], args[15]
if not files_ready_for_mode(mode, mp4, png, insta, tiktok, yt, fb):
return (keep, gr.update(active=False), keep, "", T(lang, "m_files_wait"), "",
gr.update(visible=False), gr.update(visible=True))
_up_cleanup()
jid = uuid.uuid4().hex
job = {"id": jid, "state": "running", "created": time.time(), "stage": ("up_check", {}),
"total_bytes": 0, "done_bytes": 0, "completed_bytes": 0, "result": None}
with _UP_LOCK:
_UP_JOBS[jid] = job
threading.Thread(target=_run_upload_job, args=(job, list(args)), name=f"upload-{jid[:8]}", daemon=True).start()
print(f"UPLOAD JOB {jid} started ({mode})")
return (jid, gr.update(active=True), gr.update(interactive=False, value=T(lang, "uploading")),
upload_progress_html(job, lang), "", "", gr.update(visible=False), gr.update(visible=False))
def _up_outputs(job, lang):
"""progress_html, output, share_url, share_col, result_group for a job."""
if job["state"] == "done":
message, url, share_u, _ = job["result"] or ("", "", gr.update(visible=False), None)
return [upload_progress_html(job, lang), message, url, share_u, gr.update(visible=True)]
keep = gr.update()
return [upload_progress_html(job, lang), keep, keep, keep, gr.update(visible=False)]
def poll_upload(job_value, lang, *file_values):
"""Every 2 s while an upload runs. Outputs: job_timer, upload_btn + _up_outputs."""
lang = lang if lang in LANG_CODES else DEFAULT_LANG
job, _, _ = _up_from_box(job_value)
keep = gr.update()
button = refresh_upload_button(*file_values[:7], lang)
if job is None:
return [gr.update(active=False), button, "", T(lang, "up_lost"), keep, keep, gr.update(visible=True)]
if job["state"] == "running":
return [gr.update(active=True), gr.update(interactive=False, value=T(lang, "uploading"))] + _up_outputs(job, lang)
return [gr.update(active=False), button] + _up_outputs(job, lang)
def recover_upload(job_value, lang):
"""Page (re)loaded: brings back this browser's last upload. Outputs: job_box, job_timer + _up_outputs."""
lang = lang if lang in LANG_CODES else DEFAULT_LANG
job, jid, age = _up_from_box(job_value)
keep = gr.update()
if job is None:
lost = bool(jid) and age is not None and age < UP_LOST_HOURS
return ["", gr.update(active=False), "", T(lang, "up_lost") if lost else keep, keep, keep,
gr.update(visible=lost)]
return [jid, gr.update(active=job["state"] == "running")] + _up_outputs(job, lang)
SAVE_UP_JS = """
(job) => { try { if (job) localStorage.setItem('uc_job', job + '|' + Date.now()); } catch (e) {} }
"""
READ_UP_JS = """
(job, lang) => {
let j = '', l = lang;
try { j = localStorage.getItem('uc_job') || ''; l = localStorage.getItem('uc_lang') || lang; } catch (e) {}
return [j, l];
}
"""
CLEAR_UP_JS = """
() => { try { localStorage.removeItem('uc_job'); } catch (e) {} }
"""
# ============================================================
# THEME — dark editor look (same as the other apps)
# ============================================================
ACCENT = "#1ED6E0" # CapCut cyan
ACCENT_2 = "#5C7CFF" # gradient blue
BG = "#0D0E12" # app background
PANEL = "#15161C" # panels (sections, top bar)
SURFACE = "#1C1D25" # cards / blocks
INPUT = "#23242E" # fields
BORDER = "#272934"
TEXT = "#ECEDF2"
MUTED = "#8C8FA0"
def _params(fn):
try:
return inspect.signature(fn).parameters
except (TypeError, ValueError):
return {}
def _accepts(fn):
ps = _params(fn)
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in ps.values()):
return lambda name: True
return lambda name: name in ps
# same colours for light and dark mode: the app is always dark
PALETTE = {
"body_background_fill": BG,
"body_text_color": TEXT,
"body_text_color_subdued": MUTED,
"background_fill_primary": PANEL,
"background_fill_secondary": SURFACE,
"border_color_primary": BORDER,
"border_color_accent": ACCENT,
"color_accent": ACCENT,
"color_accent_soft": "rgba(30, 214, 224, 0.14)",
"block_background_fill": SURFACE,
"block_border_color": BORDER,
"block_border_width": "1px",
"block_label_background_fill": SURFACE,
"block_label_text_color": MUTED,
"block_label_border_color": BORDER,
"block_title_text_color": MUTED,
"block_info_text_color": MUTED,
"block_radius": "10px",
"block_shadow": "none",
"panel_background_fill": PANEL,
"panel_border_color": BORDER,
"input_background_fill": INPUT,
"input_background_fill_focus": "#292A35",
"input_background_fill_hover": "#292A35",
"input_border_color": "#30323F",
"input_border_color_focus": ACCENT,
"input_border_color_hover": "#3B3E4D",
"input_placeholder_color": "#5F6273",
"input_radius": "8px",
"button_primary_background_fill": f"linear-gradient(90deg, {ACCENT}, {ACCENT_2})",
"button_primary_background_fill_hover": "linear-gradient(90deg, #45E2EA, #7690FF)",
"button_primary_text_color": "#06131A",
"button_primary_text_color_hover": "#06131A",
"button_primary_border_color": "transparent",
"button_secondary_background_fill": INPUT,
"button_secondary_background_fill_hover": "#2D2E3A",
"button_secondary_text_color": TEXT,
"button_secondary_text_color_hover": TEXT,
"button_secondary_border_color": "#30323F",
"button_large_radius": "8px",
"button_small_radius": "8px",
"slider_color": ACCENT,
"loader_color": ACCENT,
"link_text_color": ACCENT,
"link_text_color_hover": "#6BE9EF",
"shadow_drop": "none",
"shadow_drop_lg": "0 12px 32px rgba(0, 0, 0, 0.45)",
"shadow_spread": "0px",
"checkbox_background_color": INPUT,
"checkbox_border_color": "#3B3E4D",
"checkbox_background_color_selected": ACCENT,
"checkbox_label_background_fill": INPUT,
"checkbox_label_background_fill_selected": "rgba(30, 214, 224, 0.16)",
"checkbox_label_border_color_selected": ACCENT,
"checkbox_label_text_color": TEXT,
"checkbox_label_text_color_selected": TEXT,
"table_border_color": BORDER,
}
def make_theme():
font = [gr.themes.GoogleFont("Manrope"), "ui-sans-serif", "system-ui", "sans-serif"]
try:
theme = gr.themes.Base(
primary_hue=gr.themes.colors.cyan,
secondary_hue=gr.themes.colors.blue,
neutral_hue=gr.themes.colors.zinc,
font=font,
radius_size=gr.themes.sizes.radius_md,
text_size=gr.themes.sizes.text_sm,
spacing_size=gr.themes.sizes.spacing_md,
)
except Exception:
theme = gr.themes.Base()
ok = _accepts(theme.set)
kw = {}
for k, v in PALETTE.items():
for name in (k, k + "_dark"):
if ok(name):
kw[name] = v
try:
return theme.set(**kw)
except Exception:
return theme
THEME = make_theme()
# Always dark + warnings stand out: the Status box turns amber when a message contains "⚠"
# (and cyan when everything went through).
PAGE_JS = """
() => {
document.body.classList.add('dark');
document.documentElement.classList.add('dark');
setInterval(() => {
const box = document.querySelector('#status_box');
const ta = box && box.querySelector('textarea');
if (!box || !ta) return;
const v = ta.value || '';
box.classList.toggle('ap-warn-box', v.includes('⚠'));
box.classList.toggle('ap-ok-box', !v.includes('⚠') && v.includes('✓'));
}, 600);
}
"""
# Browser-side translations (Copy Link button texts)
I18N_JS = """
() => {
window.__ucI18n = %s;
window.__ucL = (k) => {
const all = window.__ucI18n;
const d = all[window.__ucLang] || all.en;
return (d && d[k]) || all.en[k] || k;
};
}
""" % json.dumps(JS_I18N, ensure_ascii=False)
# remember the chosen language in this browser and restore it on the next visit
READ_LANG_JS = """
(lang) => {
let l = lang;
try { l = localStorage.getItem('uc_lang') || lang; } catch (e) {}
window.__ucLang = l;
document.documentElement.lang = l;
return l;
}
"""
SET_LANG_JS = """
(lang) => {
window.__ucLang = lang;
document.documentElement.lang = lang;
try { localStorage.setItem('uc_lang', lang); } catch (e) {}
}
"""
def brand_html(lang=DEFAULT_LANG):
return f"""
<div class="ap-brand">
<div class="ap-logo">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3l6 6h-4v6h-4V9H6z M5 17h14v3H5z" fill="#06131A"/>
</svg>
</div>
<div>
<div class="ap-title">{T(lang, "app_title")}</div>
<div class="ap-sub">{T(lang, "app_sub")}</div>
</div>
</div>
"""
def mode_choices(lang=DEFAULT_LANG):
# label shown to the user, value stays "Long"/"Shorts" for the upload logic
return [(T(lang, "mode_long"), "Long"), (T(lang, "mode_shorts"), "Shorts")]
css = f"""
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&display=swap');
:root {{
--ap-bg: {BG}; --ap-panel: {PANEL}; --ap-surface: {SURFACE}; --ap-input: {INPUT};
--ap-border: {BORDER}; --ap-text: {TEXT}; --ap-muted: {MUTED};
--ap-accent: {ACCENT}; --ap-accent-2: {ACCENT_2}; --ap-on-accent: #06131A;
}}
html, body, gradio-app {{ background: var(--ap-bg) !important; }}
.gradio-container {{
max-width: 1280px !important; width: 100% !important;
margin-left: auto !important; margin-right: auto !important;
padding: 14px 18px 28px !important;
background: var(--ap-bg) !important; color: var(--ap-text);
}}
footer, .gradio-footer {{ display: none !important; visibility: hidden; height: 0; }}
::-webkit-scrollbar {{ width: 10px; height: 10px; }}
::-webkit-scrollbar-track {{ background: transparent; }}
::-webkit-scrollbar-thumb {{ background: #2B2C38; border-radius: 8px; border: 2px solid var(--ap-bg); }}
/* ---- top bar ---- */
#header_row {{
background: var(--ap-panel); border: 1px solid var(--ap-border); border-radius: 14px;
padding: 8px 10px 8px 14px !important; align-items: center !important; margin-bottom: 6px;
}}
#header_row .block, #header_row .form, #header_row .html-container {{
background: transparent !important; border: none !important; box-shadow: none !important; padding: 0 !important;
}}
.ap-brand {{ display: flex; align-items: center; gap: 12px; }}
.ap-logo {{
width: 36px; height: 36px; border-radius: 10px; flex: none; display: grid; place-items: center;
background: linear-gradient(135deg, var(--ap-accent), var(--ap-accent-2));
box-shadow: 0 0 22px rgba(30, 214, 224, 0.28);
}}
.ap-title {{ font-weight: 800; font-size: 15px; color: var(--ap-text); line-height: 1.15; }}
.ap-sub {{ font-size: 12px; color: var(--ap-muted); line-height: 1.3; }}
#reset_btn {{ min-width: 130px; }}
#lang_col {{ min-width: 150px !important; max-width: 200px; }}
#lang_dd, #lang_dd .wrap, #lang_dd .wrap-inner {{ background: var(--ap-input) !important; border-radius: 8px !important; }}
#lang_dd {{ border: 1px solid #30323F !important; }}
#lang_dd input {{ font-weight: 700 !important; color: var(--ap-text) !important; cursor: pointer; }}
/* ---- section cards ---- */
.section-card {{
background: var(--ap-panel) !important; border: 1px solid var(--ap-border) !important;
border-radius: 14px !important; padding: 16px 18px !important; margin-bottom: 6px; gap: 12px !important;
}}
.section-card h3 {{ font-size: 15px !important; font-weight: 800 !important; color: var(--ap-text) !important; margin: 0 0 2px !important; }}
span[data-testid="block-info"] {{ font-size: 12px !important; font-weight: 600 !important; color: var(--ap-muted) !important; }}
.matched-height {{ height: 480px !important; overflow-y: auto !important; }}
#author_name_box input {{ text-transform: uppercase; font-weight: 700; letter-spacing: .3px; }}
/* Upload type as a segmented control */
#mode_radio .wrap {{ gap: 6px !important; }}
#mode_radio label {{
background: var(--ap-input) !important; border: 1px solid #30323F !important;
border-radius: 9px !important; padding: 8px 18px !important; font-weight: 700 !important;
}}
#mode_radio label.selected, #mode_radio label:has(input:checked) {{
background: rgba(30, 214, 224, 0.14) !important; border-color: var(--ap-accent) !important;
color: var(--ap-text) !important;
}}
/* ---- buttons ---- */
button.primary {{
background: linear-gradient(90deg, var(--ap-accent), var(--ap-accent-2)) !important;
color: var(--ap-on-accent) !important; border: none !important; font-weight: 800 !important;
}}
button.primary:hover {{ filter: brightness(1.08); }}
button.primary:disabled, button.primary[disabled] {{ filter: grayscale(.7) brightness(.6); cursor: not-allowed; }}
button:focus-visible, input:focus-visible, textarea:focus-visible {{
outline: 2px solid var(--ap-accent) !important; outline-offset: 2px;
}}
#upload_row {{ justify-content: center; margin: 14px 0 6px; }}
#upload_btn {{ min-width: 360px; min-height: 52px; font-size: 16px !important; letter-spacing: .3px; border-radius: 12px !important; }}
/* ---- status: warnings stand out ---- */
#status_box textarea {{ font-weight: 600; line-height: 1.5; }}
#status_box.ap-warn-box textarea {{
border-color: #ffaa28 !important; background: rgba(255, 170, 40, .08) !important; color: #ffe2b8 !important;
}}
#status_box.ap-ok-box textarea {{ border-color: var(--ap-accent) !important; }}
@media (max-width: 900px) {{ #header_row {{ flex-wrap: wrap; }} .matched-height {{ height: auto !important; }} }}
@media (prefers-reduced-motion: reduce) {{ * {{ transition: none !important; }} }}
"""
# Theme: Gradio 6 takes it in mount_gradio_app(); Gradio 4/5 in Blocks().
# CSS is injected with a <style> tag, which works in every version.
_MOUNT_TAKES_THEME = "theme" in _params(gr.mount_gradio_app)
BLOCKS_KWARGS = {"title": "Upload Center"}
if not _MOUNT_TAKES_THEME and "theme" in _params(gr.Blocks.__init__):
BLOCKS_KWARGS["theme"] = THEME
L0 = DEFAULT_LANG # language the page is built in (the dropdown switches it live)
# ---------- UI ----------
with gr.Blocks(**BLOCKS_KWARGS) as app:
gr.HTML(f"<style>{css}</style>")
with gr.Row(elem_id="header_row", equal_height=True):
with gr.Column(scale=6):
brand = gr.HTML(brand_html(L0))
with gr.Column(scale=1, min_width=150, elem_id="lang_col"):
lang_dd = gr.Dropdown(choices=LANG_CHOICES, value=L0, show_label=False, container=False,
interactive=True, elem_id="lang_dd")
with gr.Column(scale=1, min_width=140):
refresh_all_btn = gr.Button(T(L0, "reset"), variant="secondary", elem_id="reset_btn")
with gr.Group(elem_classes=["section-card"]):
sec_author_md = gr.Markdown(T(L0, "sec_author"))
author_name = gr.Textbox(
label=T(L0, "author_label"),
elem_id="author_name_box",
**({"max_length": AUTHOR_MAX_CHARS} if "max_length" in _params(gr.Textbox.__init__) else {})
)
author_name.input(
fn=sanitize_author_input,
inputs=author_name,
outputs=author_name
)
mode = gr.Radio(mode_choices(L0), label=T(L0, "mode_label"), value="Long", elem_id="mode_radio")
# Long Upload Section
long_col = gr.Column(visible=True)
with long_col:
with gr.Group(elem_classes=["section-card"]):
sec_long_md = gr.Markdown(T(L0, "sec_long"))
with gr.Row():
with gr.Column(scale=1, elem_classes=["matched-height"]):
game_name_long = gr.Textbox(label=T(L0, "game_name"))
part_long = gr.Textbox(label=T(L0, "part"))
yt_playlist_long = gr.Textbox(label=T(L0, "yt_playlist"))
game_url_long = gr.Textbox(label=T(L0, "game_url"))
synopsis_long = gr.Textbox(label=T(L0, "yt_synopsis"), lines=4)
chapters_long = gr.Textbox(label=T(L0, "yt_chapters"), lines=4)
instructions_long = gr.Textbox(label=T(L0, "instructions"), lines=4)
with gr.Column(scale=1, elem_classes=["matched-height"]):
mp4_long = gr.File(label=T(L0, "mp4_long"), file_types=[".mp4"])
png_long = gr.File(label=T(L0, "thumb"), file_types=THUMB_FILE_TYPES)
save_long = gr.File(
label=T(L0, "save_file"),
file_types=[".zip", ".rar"]
)
# Shorts Upload Section
shorts_col = gr.Column(visible=False)
with shorts_col:
with gr.Group(elem_classes=["section-card"]):
sec_shorts_md = gr.Markdown(T(L0, "sec_shorts"))
with gr.Row():
with gr.Column(scale=1, elem_classes=["matched-height"]):
game_name_shorts = gr.Textbox(label=T(L0, "game_name"))
part_shorts = gr.Textbox(label=T(L0, "part"))
yt_playlist_shorts = gr.Textbox(label=T(L0, "yt_playlist"))
game_url_shorts = gr.Textbox(label=T(L0, "game_url"))
synopsis_shorts = gr.Textbox(label=T(L0, "video_synopsis"), lines=4)
with gr.Column(scale=1, elem_classes=["matched-height"]):
insta = gr.File(label=T(L0, "short_file", p="INSTSTORIES"), file_types=[".mp4"])
tiktok = gr.File(label=T(L0, "short_file", p="TIKTOK"), file_types=[".mp4"])
yt = gr.File(label=T(L0, "short_file", p="YTBSTORIES"), file_types=[".mp4"])
fb = gr.File(label=T(L0, "short_file", p="FACEBOOK"), file_types=[".mp4"])
# Upload Button (placed after file selection)
with gr.Row(elem_id="upload_row"):
upload_btn = gr.Button(
T(L0, "upload_waiting"),
variant="primary",
interactive=False,
elem_id="upload_btn"
)
# background upload: this browser's job id, the 2-second progress check and the bar
job_box = gr.Textbox(value="", visible=False)
job_timer = gr.Timer(UP_POLL_SECONDS, active=False)
progress_html = gr.HTML("", elem_id="upload_progress")
# Result: hidden until an upload finishes (success or a message to read)
with gr.Group(elem_classes=["section-card"], visible=False) as result_group:
sec_result_md = gr.Markdown(T(L0, "sec_result"))
output = gr.Textbox(
label=T(L0, "status"),
interactive=False,
lines=3,
elem_id="status_box"
)
share_col = gr.Column(visible=False)
with share_col:
share_url = gr.Textbox(
label=T(L0, "share_url"),
interactive=False,
elem_id="share_url_box"
)
copy_btn = gr.Button(
T(L0, "copy_link"),
elem_id="copy_button"
)
copy_btn.click(
fn=None,
inputs=share_url,
outputs=[],
js="""
async (url) => {
const L = (k) => (window.__ucL ? window.__ucL(k) : k);
const button = document.querySelector('#copy_button') || document.activeElement;
const oldText = button.innerHTML;
const flash = (text) => {
button.innerHTML = text;
setTimeout(() => { button.innerHTML = oldText; }, 1500);
};
if (!url) { flash(L('no_link')); return; }
try {
await navigator.clipboard.writeText(url);
flash(L('copied'));
} catch (e) {
flash(L('copy_failed'));
}
}
"""
)
file_inputs = [mode, mp4_long, png_long, insta, tiktok, yt, fb, lang_dd]
# Show/Hide Logic
def update_visibility(selected_mode):
if selected_mode == "Long":
return (
gr.update(visible=True), # long
gr.update(visible=False), # shorts
gr.update(visible=False), # share link (reset on mode change)
gr.update(visible=False), # result
)
else: # Shorts
return (
gr.update(visible=False),
gr.update(visible=True),
gr.update(visible=False),
gr.update(visible=False),
)
mode.change(
fn=update_visibility,
inputs=mode,
outputs=[
long_col,
shorts_col,
share_col,
result_group,
]
).then(
# Switching mode changes which files are actually required, so re-lock/
# re-check the Upload button against the newly-relevant fields.
fn=refresh_upload_button,
inputs=file_inputs,
outputs=upload_btn
)
# ---- Upload-readiness wiring ----
# .upload() only fires once a file has FULLY arrived on the server, so it's the
# right signal to re-check whether the button should unlock. .clear() re-locks
# it if someone removes a file after selecting it.
for comp in (mp4_long, png_long, insta, tiktok, yt, fb):
comp.upload(
fn=refresh_upload_button,
inputs=file_inputs,
outputs=upload_btn
)
comp.clear(
fn=refresh_upload_button,
inputs=file_inputs,
outputs=upload_btn
)
# Reset All (keeps the chosen language)
def refresh_all(lang):
return (
gr.update(value=""), # author_name
gr.update(value=None), # mp4_long
gr.update(value=None), # png_long
gr.update(value=None), # save_long
gr.update(value=""), # game_name_long
gr.update(value=""), # part_long
gr.update(value=""), # yt_playlist_long
gr.update(value=""), # game_url_long
gr.update(value=""), # synopsis_long
gr.update(value=""), # chapters_long
gr.update(value=""), # instructions_long
gr.update(value=None), # insta
gr.update(value=None), # tiktok
gr.update(value=None), # yt
gr.update(value=None), # fb
gr.update(value=""), # game_name_shorts
gr.update(value=""), # part_shorts
gr.update(value=""), # yt_playlist_shorts
gr.update(value=""), # game_url_shorts
gr.update(value=""), # synopsis_shorts
"", # output
gr.update(value=None), # share_url
"Long", # mode
gr.update(visible=True), # long_col
gr.update(visible=False), # shorts_col
gr.update(visible=False), # share_col
gr.update(interactive=False, value=T(lang, "upload_waiting")), # upload_btn
gr.update(visible=False), # result_group
)
refresh_all_btn.click(
fn=refresh_all,
inputs=[lang_dd],
outputs=[
author_name,
mp4_long,
png_long,
save_long,
game_name_long,
part_long,
yt_playlist_long,
game_url_long,
synopsis_long,
chapters_long,
instructions_long,
insta,
tiktok,
yt,
fb,
game_name_shorts,
part_shorts,
yt_playlist_shorts,
game_url_shorts,
synopsis_shorts,
output,
share_url,
mode,
long_col,
shorts_col,
share_col,
upload_btn,
result_group,
],
js="(lang) => { window.scrollTo({ top: 0, behavior: 'smooth' }); return lang; }",
)
# Upload: starts the job (returns at once); job_timer shows the bar and, at the end, the result
upload_inputs = [
mode, author_name,
mp4_long, png_long, save_long,
game_name_long, part_long, yt_playlist_long, game_url_long,
synopsis_long, chapters_long, instructions_long,
insta, yt, tiktok, fb,
game_name_shorts, part_shorts, yt_playlist_shorts, game_url_shorts, synopsis_shorts,
lang_dd,
]
result_outputs = [progress_html, output, share_url, share_col, result_group]
upload_btn.click(
fn=start_upload,
inputs=upload_inputs + [job_box],
outputs=[job_box, job_timer, upload_btn] + result_outputs,
show_progress="hidden",
).then(fn=None, inputs=[job_box], outputs=None, js=SAVE_UP_JS)
job_timer.tick(
fn=poll_upload,
inputs=[job_box, lang_dd, mode, mp4_long, png_long, insta, tiktok, yt, fb],
outputs=[job_timer, upload_btn] + result_outputs,
show_progress="hidden",
concurrency_limit=None,
)
# Reset All forgets the upload shown on this page (one still running finishes on the server)
refresh_all_btn.click(fn=lambda: ("", gr.update(active=False), ""), inputs=None,
outputs=[job_box, job_timer, progress_html], js=CLEAR_UP_JS, show_progress="hidden")
# ------------------------------------------------------------
# LANGUAGE: translate every label, title and button on the page
# ------------------------------------------------------------
def apply_language(lang, selected_mode, mp4, png, ins, tik, ytb, fbk):
lang = lang if lang in LANG_CODES else DEFAULT_LANG
u = gr.update
return (
brand_html(lang), # brand
u(value=T(lang, "reset")), # refresh_all_btn
T(lang, "sec_author"), # sec_author_md
u(label=T(lang, "author_label")), # author_name
u(label=T(lang, "mode_label"), choices=mode_choices(lang), value=selected_mode),
T(lang, "sec_long"), # sec_long_md
u(label=T(lang, "game_name")),
u(label=T(lang, "part")),
u(label=T(lang, "yt_playlist")),
u(label=T(lang, "game_url")),
u(label=T(lang, "yt_synopsis")),
u(label=T(lang, "yt_chapters")),
u(label=T(lang, "instructions")),
u(label=T(lang, "mp4_long")),
u(label=T(lang, "thumb")),
u(label=T(lang, "save_file")),
T(lang, "sec_shorts"), # sec_shorts_md
u(label=T(lang, "game_name")),
u(label=T(lang, "part")),
u(label=T(lang, "yt_playlist")), # yt_playlist_shorts
u(label=T(lang, "game_url")),
u(label=T(lang, "video_synopsis")),
u(label=T(lang, "short_file", p="INSTSTORIES")),
u(label=T(lang, "short_file", p="TIKTOK")),
u(label=T(lang, "short_file", p="YTBSTORIES")),
u(label=T(lang, "short_file", p="FACEBOOK")),
refresh_upload_button(selected_mode, mp4, png, ins, tik, ytb, fbk, lang), # upload_btn
T(lang, "sec_result"), # sec_result_md
u(label=T(lang, "status")), # output
u(label=T(lang, "share_url")), # share_url
u(value=T(lang, "copy_link")), # copy_btn
)
lang_inputs = [lang_dd, mode, mp4_long, png_long, insta, tiktok, yt, fb]
lang_outputs = [
brand, refresh_all_btn, sec_author_md, author_name, mode,
sec_long_md, game_name_long, part_long, yt_playlist_long, game_url_long,
synopsis_long, chapters_long, instructions_long, mp4_long, png_long, save_long,
sec_shorts_md, game_name_shorts, part_shorts, yt_playlist_shorts, game_url_shorts, synopsis_shorts,
insta, tiktok, yt, fb,
upload_btn, sec_result_md, output, share_url, copy_btn,
]
lang_dd.change(fn=None, inputs=[lang_dd], outputs=None, js=SET_LANG_JS)
lang_dd.change(fn=apply_language, inputs=lang_inputs, outputs=lang_outputs, show_progress="hidden")
# dark look + highlighted warnings + browser texts (all run in the browser)
app.load(fn=None, inputs=None, outputs=None, js=PAGE_JS)
app.load(fn=None, inputs=None, outputs=None, js=I18N_JS)
# page (re)loaded during / after an upload: bring it back
app.load(fn=recover_upload, inputs=[job_box, lang_dd], outputs=[job_box, job_timer] + result_outputs,
js=READ_UP_JS, show_progress="hidden", concurrency_limit=None)
# restore the language chosen on the last visit
app.load(fn=lambda l: l if l in LANG_CODES else DEFAULT_LANG, inputs=[lang_dd], outputs=[lang_dd],
js=READ_LANG_JS, show_progress="hidden").then(
fn=apply_language, inputs=lang_inputs, outputs=lang_outputs, show_progress="hidden")
# ============================================================
# SERVER + ACCESS GATE (admin.gamify.pt)
# The app is served by FastAPI/uvicorn so the gate can check EVERY request.
# The gate is ON unless GAMIFY_GATE=off is set explicitly; if gamify_gate.py
# can't be loaded, everything is blocked (the app is never opened by accident).
# ============================================================
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
server = FastAPI()
@server.get("/healthz")
def healthz():
return {"status": "ok"}
_mount_kwargs = {"theme": THEME} if _MOUNT_TAKES_THEME else {}
server = gr.mount_gradio_app(server, app, path="/", **_mount_kwargs)
if os.environ.get("GAMIFY_GATE", "on").strip().lower() == "off":
print("GAMIFY GATE: OFF — the app is open to everyone (GAMIFY_GATE=off)")
else:
try:
from gamify_gate import GamifyGate
server.add_middleware(GamifyGate)
print("GAMIFY GATE: ON — access only through admin.gamify.pt")
except Exception as e:
print(f"GAMIFY GATE: could not be loaded ({e}) — blocking all access")
@server.middleware("http")
async def _block_everything(request, call_next):
if request.url.path == "/healthz":
return await call_next(request)
return PlainTextResponse("Access blocked: gamify_gate.py is missing from this Space.", status_code=503)
if __name__ == "__main__":
import uvicorn
uvicorn.run(server, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))