Spaces:
Running
Running
Download app.py from Adnaplays87/Upload_Files_BackUp: direct link, hf CLI and curl.
- Browser
- Download file 97.1 kB
-
https://huggingface.co/spaces/Adnaplays87/Upload_Files_BackUp/resolve/main/app.py
- Command line
-
hf download hf://spaces/Adnaplays87/Upload_Files_BackUp/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Adnaplays87/Upload_Files_BackUp/resolve/main/app.py
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() | |
| 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") | |
| 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))) |