depoly inteligente

This commit is contained in:
2026-02-01 23:38:06 +00:00
parent 3ebe723edb
commit 935b15980c
10 changed files with 807 additions and 453 deletions

View File

@@ -1,32 +1,19 @@
from nicegui import ui
from nicegui import ui, run
import os
import threading
import json
import time
import yt_dlp
# --- CONFIGURAÇÕES ---
DOWNLOAD_DIR = "/downloads/Youtube"
STATUS_FILE = "/app/data/dl_status.json"
# --- UTILITÁRIOS ---
def save_status(data):
try:
with open(STATUS_FILE, 'w') as f: json.dump(data, f)
except: pass
def load_status():
if not os.path.exists(STATUS_FILE): return None
try:
with open(STATUS_FILE, 'r') as f: return json.load(f)
except: return None
DOWNLOAD_DIR = "/downloads/ytdlp"
# --- WORKER (BACKEND) ---
class DownloadWorker(threading.Thread):
def __init__(self, url, format_type):
def __init__(self, url, format_type, status_callback):
super().__init__()
self.url = url
self.format_type = format_type
self.callback = status_callback # Função para atualizar o estado na Interface
self.daemon = True
self.stop_requested = False
@@ -42,33 +29,38 @@ class DownloadWorker(threading.Thread):
speed = d.get('speed', 0) or 0
speed_str = f"{speed / 1024 / 1024:.2f} MiB/s"
filename = os.path.basename(d.get('filename', 'Baixando...'))
eta = d.get('_eta_str', '?')
save_status({
# Atualiza estado em memória via callback
self.callback({
"running": True,
"file": filename,
"progress": pct,
"log": f"Baixando: {speed_str} | {d.get('_eta_str', '?')} restantes",
"stop_requested": False
"log": f"Baixando: {speed_str} | ETA: {eta}",
"status": "downloading"
})
elif d['status'] == 'finished':
save_status({
self.callback({
"running": True,
"file": "Processando...",
"progress": 99,
"log": "Convertendo/Juntando arquivos...",
"stop_requested": False
"log": "Convertendo/Juntando arquivos (ffmpeg)...",
"status": "processing"
})
def run(self):
if not os.path.exists(DOWNLOAD_DIR): os.makedirs(DOWNLOAD_DIR, exist_ok=True)
if not os.path.exists(DOWNLOAD_DIR):
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
ydl_opts = {
'outtmpl': f'{DOWNLOAD_DIR}/%(title)s.%(ext)s',
'progress_hooks': [self.progress_hook],
'nocheckcertificate': True,
'ignoreerrors': True,
'ffmpeg_location': '/usr/bin/ffmpeg'
'ignoreerrors': False, # Mudado para False para pegarmos os erros reais
'ffmpeg_location': '/usr/bin/ffmpeg',
'writethumbnail': True, # Garante metadados no arquivo final
'addmetadata': True,
}
if self.format_type == 'best':
@@ -76,111 +68,214 @@ class DownloadWorker(threading.Thread):
ydl_opts['merge_output_format'] = 'mkv'
elif self.format_type == 'audio':
ydl_opts['format'] = 'bestaudio/best'
ydl_opts['postprocessors'] = [{'key': 'FFmpegExtractAudio','preferredcodec': 'mp3','preferredquality': '192'}]
ydl_opts['postprocessors'] = [{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
'preferredquality': '192'
}]
elif self.format_type == '1080p':
ydl_opts['format'] = 'bestvideo[height<=1080]+bestaudio/best[height<=1080]'
ydl_opts['merge_output_format'] = 'mkv'
try:
save_status({"running": True, "file": "Iniciando...", "progress": 0, "log": "Conectando..."})
self.callback({"running": True, "file": "Iniciando...", "progress": 0, "log": "Conectando...", "status": "starting"})
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([self.url])
save_status({"running": False, "file": "Concluído!", "progress": 100, "log": "Sucesso."})
self.callback({"running": False, "file": "Concluído!", "progress": 100, "log": "Download finalizado com sucesso.", "status": "success"})
except Exception as e:
msg = "Cancelado." if "Cancelado" in str(e) else str(e)
save_status({"running": False, "file": "Parado", "progress": 0, "log": msg})
msg = str(e)
if "Cancelado" in msg:
log_msg = "Download cancelado pelo usuário."
else:
log_msg = f"Erro: {msg}"
self.callback({"running": False, "file": "Erro/Parado", "progress": 0, "log": log_msg, "status": "error"})
# --- FUNÇÃO AUXILIAR DE METADADOS (IO BOUND) ---
def fetch_meta(url):
try:
ydl_opts = {'quiet': True, 'nocheckcertificate': True, 'ignoreerrors': True}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
return ydl.extract_info(url, download=False)
except:
return None
# --- INTERFACE (FRONTEND) ---
class DownloaderInterface:
def __init__(self):
self.container = None
self.timer = None
self.btn_download = None
self.card_status = None
self.worker = None
# Elementos dinâmicos
self.lbl_file = None
self.progress = None
self.lbl_log = None
self.btn_stop = None
# Estado Local (Em memória)
self.state = {
"running": False,
"file": "Aguardando...",
"progress": 0,
"log": "---",
"status": "idle"
}
def start_download(self, url, fmt):
if not url:
ui.notify('Cole uma URL!', type='warning')
return
# Elementos UI
self.url_input = None
self.fmt_select = None
self.btn_check = None
self.btn_download = None
self.btn_stop = None
self.btn_reset = None
if os.path.exists(STATUS_FILE): os.remove(STATUS_FILE)
t = DownloadWorker(url, fmt)
t.start()
ui.notify('Iniciando...')
self.render_update()
self.preview_card = None
self.preview_img = None
self.preview_title = None
self.status_card = None
self.lbl_file = None
self.progress_bar = None
self.lbl_log = None
def update_state(self, new_data):
"""Callback chamada pelo Worker (thread) para atualizar o dict de estado."""
self.state.update(new_data)
async def check_url(self):
url = self.url_input.value
if not url:
ui.notify('Insira uma URL primeiro!', type='warning')
return
self.btn_check.props('loading')
self.lbl_log.text = "Buscando informações do vídeo..."
# Roda em thread separada para não travar a UI
info = await run.io_bound(fetch_meta, url)
self.btn_check.props(remove='loading')
if info and 'title' in info:
self.preview_card.visible = True
self.preview_title.text = info.get('title', 'Sem título')
self.preview_img.set_source(info.get('thumbnail', ''))
self.btn_download.enable()
self.status_card.visible = True
self.lbl_log.text = "Vídeo encontrado. Pronto para baixar."
else:
ui.notify('Não foi possível obter dados do vídeo. Verifique o link.', type='negative')
self.lbl_log.text = "Erro ao buscar metadados."
def start_download(self):
url = self.url_input.value
fmt = self.fmt_select.value
# Reset visual
self.state['progress'] = 0
self.btn_download.disable()
self.btn_check.disable()
self.url_input.disable()
self.btn_reset.visible = False
# Inicia Worker
self.worker = DownloadWorker(url, fmt, self.update_state)
self.worker.start()
ui.notify('Download iniciado!')
def stop_download(self):
data = load_status()
if data:
data['stop_requested'] = True
save_status(data)
ui.notify('Parando...')
if self.worker and self.worker.is_alive():
self.worker.stop_requested = True
self.worker.join(timeout=1.0)
ui.notify('Solicitação de cancelamento enviada.')
def reset_ui(self):
"""Reseta a interface para um novo download"""
self.url_input.value = ''
self.url_input.enable()
self.btn_check.enable()
self.btn_download.disable()
self.preview_card.visible = False
self.status_card.visible = False
self.btn_reset.visible = False
self.lbl_log.text = '---'
self.state = {"running": False, "file": "Aguardando...", "progress": 0, "log": "---", "status": "idle"}
def ui_update_loop(self):
"""Timer que atualiza os elementos visuais com base no self.state"""
# Sincroniza dados da memória com os componentes
self.lbl_file.text = f"Arquivo: {self.state.get('file')}"
self.progress_bar.value = self.state.get('progress', 0) / 100
self.lbl_log.text = self.state.get('log')
status = self.state.get('status')
is_running = self.state.get('running', False)
# Controle de visibilidade do botão Cancelar
if self.btn_stop:
self.btn_stop.visible = is_running
# Tratamento de finalização/erro para mostrar botão de "Novo"
if status in ['success', 'error'] and not is_running:
self.btn_reset.visible = True
if status == 'error':
self.lbl_log.classes('text-red-500', remove='text-gray-500')
else:
self.lbl_log.classes('text-green-600', remove='text-gray-500')
else:
self.lbl_log.classes('text-gray-500', remove='text-red-500 text-green-600')
def render(self):
ui.label('📺 YouTube Downloader').classes('text-xl font-bold mb-2')
ui.label('📺 YouTube Downloader (Docker)').classes('text-xl font-bold mb-2')
# --- INPUT ---
# --- ÁREA DE INPUT ---
with ui.card().classes('w-full p-4 mb-4'):
url_input = ui.input('URL do Vídeo').classes('w-full').props('clearable placeholder="https://youtube.com/..."')
with ui.row().classes('items-center mt-2'):
fmt_select = ui.select(
with ui.row().classes('w-full items-center gap-2'):
self.url_input = ui.input('URL do Vídeo').classes('flex-grow').props('clearable placeholder="https://..."')
self.btn_check = ui.button('Verificar', on_click=self.check_url).props('icon=search color=secondary')
with ui.row().classes('items-center mt-2 gap-4'):
self.fmt_select = ui.select(
{'best': 'Melhor Qualidade (MKV)', '1080p': 'Limitado a 1080p (MKV)', 'audio': 'Apenas Áudio (MP3)'},
value='best', label='Formato'
).classes('w-64')
self.btn_download = ui.button('Baixar', on_click=lambda: self.start_download(url_input.value, fmt_select.value))\
.props('icon=download color=primary')
self.btn_download = ui.button('Baixar Agora', on_click=self.start_download)\
.props('icon=download color=primary').classes('w-40')
self.btn_download.disable() # Começa desabilitado até verificar
# --- MONITORAMENTO ---
# CORREÇÃO AQUI: Criamos o card primeiro, depois definimos visibilidade
self.card_status = ui.card().classes('w-full p-4')
self.card_status.visible = False # Esconde inicialmente
# --- PREVIEW (Melhoria 7) ---
self.preview_card = ui.card().classes('w-full p-2 mb-4 bg-gray-100 flex-row gap-4 items-center')
self.preview_card.visible = False
with self.preview_card:
self.preview_img = ui.image().classes('w-32 h-24 rounded object-cover')
with ui.column():
ui.label('Vídeo Detectado:').classes('text-xs text-gray-600 uppercase font-bold')
self.preview_title = ui.label('').classes('font-bold text-md leading-tight')
# --- STATUS E MONITORAMENTO ---
self.status_card = ui.card().classes('w-full p-4')
self.status_card.visible = False
with self.card_status:
ui.label('Progresso').classes('font-bold')
with self.status_card:
with ui.row().classes('w-full justify-between items-center'):
ui.label('Status do Processo').classes('font-bold')
self.btn_reset = ui.button('Baixar Outro', on_click=self.reset_ui)\
.props('icon=refresh flat color=primary').classes('text-sm')
self.btn_reset.visible = False
self.lbl_file = ui.label('Aguardando...')
self.progress = ui.linear_progress(value=0).classes('w-full')
self.progress_bar = ui.linear_progress(value=0).classes('w-full my-2')
self.lbl_log = ui.label('---').classes('text-sm text-gray-500 font-mono')
with ui.row().classes('w-full justify-end mt-2'):
self.btn_stop = ui.button('🛑 Cancelar', on_click=self.stop_download).props('color=red flat')
self.btn_stop = ui.button('🛑 Cancelar Download', on_click=self.stop_download).props('color=red flat')
self.timer = ui.timer(1.0, self.render_update)
def render_update(self):
data = load_status()
if not data:
if self.card_status: self.card_status.visible = False
if self.btn_download: self.btn_download.enable()
return
# Atualiza UI
is_running = data.get('running', False)
if self.btn_download:
if is_running: self.btn_download.disable()
else: self.btn_download.enable()
if self.card_status: self.card_status.visible = True
if self.lbl_file: self.lbl_file.text = f"Arquivo: {data.get('file', '?')}"
if self.progress: self.progress.value = data.get('progress', 0) / 100
if self.lbl_log: self.lbl_log.text = data.get('log', '')
if self.btn_stop: self.btn_stop.visible = is_running
# Timer para atualizar UI a partir do estado em memória
self.timer = ui.timer(0.5, self.ui_update_loop)
# --- INICIALIZADOR ---
def create_ui():
dl = DownloaderInterface()
dl.container = ui.column().classes('w-full h-full p-4 gap-4')
dl.container = ui.column().classes('w-full h-full p-4 max-w-4xl mx-auto')
with dl.container:
dl.render()