corrigido o progresso na inteface e o encoder
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,6 @@
|
|||||||
import subprocess
|
import subprocess
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from database import FFmpegProfile
|
from database import FFmpegProfile
|
||||||
from core.state import state
|
from core.state import state
|
||||||
|
|
||||||
@@ -11,111 +12,108 @@ class FFmpegEngine:
|
|||||||
self.profile = FFmpegProfile.get_or_none(FFmpegProfile.is_active == True)
|
self.profile = FFmpegProfile.get_or_none(FFmpegProfile.is_active == True)
|
||||||
|
|
||||||
if not self.profile:
|
if not self.profile:
|
||||||
state.log("⚠️ Nenhum perfil FFmpeg ativo!")
|
state.log("⚠️ AVISO: Nenhum perfil FFmpeg ativo! Usando defaults.")
|
||||||
|
|
||||||
def get_file_info(self, filepath):
|
def get_file_info(self, filepath):
|
||||||
cmd = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', filepath]
|
cmd = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', filepath]
|
||||||
try:
|
try:
|
||||||
output = subprocess.check_output(cmd).decode('utf-8')
|
output = subprocess.check_output(cmd).decode('utf-8')
|
||||||
return json.loads(output)
|
return json.loads(output)
|
||||||
except: return None
|
except Exception as e:
|
||||||
|
state.log(f"Erro FFprobe: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def get_duration(self, filepath):
|
def get_duration(self, filepath):
|
||||||
try: return float(self.get_file_info(filepath)['format']['duration'])
|
try: return float(self.get_file_info(filepath)['format']['duration'])
|
||||||
except: return 0
|
except: return 0
|
||||||
|
|
||||||
def build_command(self, input_file, output_file):
|
def build_command(self, input_file, output_file):
|
||||||
if not self.profile: raise Exception("Perfil não selecionado")
|
if not self.profile: raise Exception("Perfil não configurado.")
|
||||||
p = self.profile
|
p = self.profile
|
||||||
metadata = self.get_file_info(input_file)
|
metadata = self.get_file_info(input_file)
|
||||||
if not metadata: raise Exception("Metadados inválidos")
|
if not metadata: raise Exception("Arquivo inválido.")
|
||||||
|
|
||||||
cmd = ['ffmpeg', '-y']
|
cmd = ['ffmpeg', '-y']
|
||||||
|
|
||||||
# --- HARDWARE INIT ---
|
# --- CONFIGURAÇÃO DE HARDWARE OTIMIZADA ---
|
||||||
# VAAPI (Intel Linux/Docker) - O Jeito Correto
|
video_filters = []
|
||||||
|
|
||||||
if 'vaapi' in p.video_codec:
|
if 'vaapi' in p.video_codec:
|
||||||
cmd.extend(['-init_hw_device', 'vaapi=va:/dev/dri/renderD128'])
|
# TENTATIVA OTIMIZADA: HWAccel habilitado na entrada
|
||||||
cmd.extend(['-hwaccel', 'vaapi', '-hwaccel_output_format', 'vaapi', '-hwaccel_device', 'va'])
|
# Isso reduz a CPU drasticamente se o decode for suportado
|
||||||
cmd.extend(['-i', input_file])
|
cmd.extend(['-hwaccel', 'vaapi'])
|
||||||
# Filtro essencial para VAAPI: garante formato NV12 na GPU
|
cmd.extend(['-hwaccel_device', '/dev/dri/renderD128'])
|
||||||
# Mas como usamos hwaccel_output_format vaapi, o filtro pode ser simplificado ou scale_vaapi
|
cmd.extend(['-hwaccel_output_format', 'vaapi'])
|
||||||
# Vamos usar o padrão seguro que funciona em Haswell:
|
|
||||||
video_filters = 'format=nv12,hwupload'
|
# Não precisamos de 'hwupload' se o output format já é vaapi
|
||||||
|
# Mas vamos deixar o filtro scale_vaapi caso precise redimensionar (opcional)
|
||||||
|
# video_filters.append('scale_vaapi=format=nv12')
|
||||||
|
|
||||||
elif 'qsv' in p.video_codec:
|
elif 'qsv' in p.video_codec:
|
||||||
cmd.extend(['-hwaccel', 'qsv', '-i', input_file])
|
cmd.extend(['-hwaccel', 'qsv', '-c:v', 'h264_qsv'])
|
||||||
video_filters = None
|
|
||||||
|
|
||||||
elif 'nvenc' in p.video_codec:
|
elif 'nvenc' in p.video_codec:
|
||||||
cmd.extend(['-hwaccel', 'cuda', '-i', input_file])
|
cmd.extend(['-hwaccel', 'cuda'])
|
||||||
video_filters = None
|
|
||||||
|
# Input e Threads (Limita CPU se cair pra software decode)
|
||||||
else:
|
cmd.extend(['-threads', '4', '-i', input_file])
|
||||||
# CPU
|
|
||||||
cmd.extend(['-i', input_file])
|
|
||||||
video_filters = None
|
|
||||||
|
|
||||||
# --- VÍDEO ---
|
# --- VÍDEO ---
|
||||||
cmd.extend(['-map', '0:v:0'])
|
cmd.extend(['-map', '0:v:0'])
|
||||||
|
|
||||||
if p.video_codec == 'copy':
|
if p.video_codec == 'copy':
|
||||||
cmd.extend(['-c:v', 'copy'])
|
cmd.extend(['-c:v', 'copy'])
|
||||||
else:
|
else:
|
||||||
cmd.extend(['-c:v', p.video_codec])
|
cmd.extend(['-c:v', p.video_codec])
|
||||||
|
|
||||||
# Se tem filtro de hardware (VAAPI precisa subir pra GPU se hwaccel falhar no decode)
|
# Filtros
|
||||||
if 'vaapi' in p.video_codec:
|
if video_filters:
|
||||||
# Se usarmos -hwaccel vaapi, o stream ja esta na GPU.
|
cmd.extend(['-vf', ",".join(video_filters)])
|
||||||
# Mas as vezes precisamos garantir o filtro scale_vaapi se fosse redimensionar.
|
|
||||||
# Para manter simples e funcional no Haswell:
|
|
||||||
# cmd.extend(['-vf', 'format=nv12,hwupload']) <--- Se nao usar hwaccel
|
|
||||||
# Com hwaccel, nao precisa do hwupload, mas precisa garantir compatibilidade
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Configs de Encoder
|
# Qualidade
|
||||||
if 'vaapi' in p.video_codec:
|
if 'vaapi' in p.video_codec:
|
||||||
# VAAPI usa QP, não CRF padrão
|
# -qp é o modo Qualidade Constante do VAAPI
|
||||||
# Se der erro, troque '-qp' por '-rc_mode CQP -global_quality'
|
# Se der erro, remova -qp e use -b:v 5M
|
||||||
cmd.extend(['-qp', str(p.crf)])
|
cmd.extend(['-qp', str(p.crf)])
|
||||||
|
|
||||||
elif 'qsv' in p.video_codec:
|
|
||||||
cmd.extend(['-global_quality', str(p.crf), '-look_ahead', '1'])
|
|
||||||
cmd.extend(['-preset', p.preset])
|
|
||||||
|
|
||||||
elif 'nvenc' in p.video_codec:
|
elif 'nvenc' in p.video_codec:
|
||||||
cmd.extend(['-cq', str(p.crf), '-preset', p.preset])
|
cmd.extend(['-cq', str(p.crf), '-preset', p.preset])
|
||||||
|
|
||||||
elif 'libx264' in p.video_codec:
|
elif 'libx264' in p.video_codec:
|
||||||
cmd.extend(['-crf', str(p.crf), '-preset', p.preset])
|
cmd.extend(['-crf', str(p.crf), '-preset', p.preset])
|
||||||
|
|
||||||
# --- ÁUDIO ---
|
# --- ÁUDIO (AAC Stereo) ---
|
||||||
allowed = p.audio_langs.split(',') if p.audio_langs else []
|
allowed_langs = [l.strip().lower() for l in (p.audio_langs or "").split(',')]
|
||||||
audio_streams = [s for s in metadata['streams'] if s['codec_type'] == 'audio']
|
audio_streams = [s for s in metadata['streams'] if s['codec_type'] == 'audio']
|
||||||
|
|
||||||
acount = 0
|
acount = 0
|
||||||
for s in audio_streams:
|
for s in audio_streams:
|
||||||
l = s.get('tags', {}).get('language', 'und')
|
lang = s.get('tags', {}).get('language', 'und').lower()
|
||||||
if not allowed or l in allowed or 'und' in allowed:
|
if not allowed_langs or lang in allowed_langs or 'und' in allowed_langs:
|
||||||
cmd.extend(['-map', f'0:{s["index"]}'])
|
cmd.extend(['-map', f'0:{s["index"]}'])
|
||||||
cmd.extend([f'-c:a:{acount}', 'aac', f'-b:a:{acount}', '192k'])
|
# AAC é leve e compatível
|
||||||
|
cmd.extend([f'-c:a:{acount}', 'aac', f'-b:a:{acount}', '192k', f'-ac:{acount}', '2'])
|
||||||
acount += 1
|
acount += 1
|
||||||
if acount == 0: cmd.extend(['-map', '0:a:0', '-c:a', 'aac'])
|
|
||||||
|
if acount == 0 and audio_streams:
|
||||||
|
cmd.extend(['-map', '0:a:0', '-c:a', 'aac', '-b:a', '192k'])
|
||||||
|
|
||||||
# --- LEGENDAS ---
|
# --- LEGENDAS (Copy) ---
|
||||||
lallowed = p.subtitle_langs.split(',') if p.subtitle_langs else []
|
sub_allowed = [l.strip().lower() for l in (p.subtitle_langs or "").split(',')]
|
||||||
sub_streams = [s for s in metadata['streams'] if s['codec_type'] == 'subtitle']
|
sub_streams = [s for s in metadata['streams'] if s['codec_type'] == 'subtitle']
|
||||||
|
|
||||||
scount = 0
|
scount = 0
|
||||||
for s in sub_streams:
|
for s in sub_streams:
|
||||||
l = s.get('tags', {}).get('language', 'und')
|
lang = s.get('tags', {}).get('language', 'und').lower()
|
||||||
if not lallowed or l in lallowed or 'und' in lallowed:
|
if not sub_allowed or lang in sub_allowed or 'und' in sub_allowed:
|
||||||
cmd.extend(['-map', f'0:{s["index"]}'])
|
cmd.extend(['-map', f'0:{s["index"]}'])
|
||||||
cmd.extend([f'-c:s:{scount}', 'copy'])
|
cmd.extend([f'-c:s:{scount}', 'copy'])
|
||||||
scount += 1
|
scount += 1
|
||||||
|
|
||||||
cmd.extend(['-metadata', 'title=', '-metadata', 'comment=CleiFlow'])
|
# Metadados
|
||||||
|
clean_title = os.path.splitext(os.path.basename(output_file))[0]
|
||||||
|
cmd.extend(['-metadata', f'title={clean_title}'])
|
||||||
cmd.append(output_file)
|
cmd.append(output_file)
|
||||||
|
|
||||||
# LOG DO COMANDO PARA DEBUG
|
|
||||||
state.log(f"🛠️ CMD: {' '.join(cmd)}")
|
state.log(f"🛠️ CMD: {' '.join(cmd)}")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -8,35 +8,43 @@ class AppState:
|
|||||||
# --- Referência ao Watcher ---
|
# --- Referência ao Watcher ---
|
||||||
self.watcher = None
|
self.watcher = None
|
||||||
|
|
||||||
# --- Lista de Tarefas (Visualização tipo Árvore/Lista) ---
|
# --- Lista de Tarefas ---
|
||||||
self.tasks = OrderedDict()
|
self.tasks = OrderedDict()
|
||||||
|
|
||||||
# --- Variáveis de Estado (Compatibilidade) ---
|
# --- Variáveis de Estado ---
|
||||||
self.current_file = ""
|
self.current_file = ""
|
||||||
self.progress = 0.0
|
|
||||||
self.status_text = "Aguardando..."
|
|
||||||
|
|
||||||
def log(self, message):
|
def log(self, message):
|
||||||
"""Adiciona log e printa no console"""
|
"""Adiciona log e printa no console"""
|
||||||
print(message)
|
print(message)
|
||||||
self.logs.append(message)
|
self.logs.append(message)
|
||||||
|
|
||||||
def update_task(self, filename, status, progress=0, label=None):
|
def update_task(self, filename, status, progress=0, label=None, file_progress=0, speed=''):
|
||||||
"""Atualiza o status de um arquivo na interface"""
|
"""
|
||||||
|
Atualiza o status de um arquivo.
|
||||||
|
progress: Progresso Global da Tarefa (0-100)
|
||||||
|
file_progress: Progresso da Conversão do Arquivo (0-100)
|
||||||
|
speed: Velocidade de conversão (ex: 8.5x)
|
||||||
|
"""
|
||||||
# Se não existe, cria
|
# Se não existe, cria
|
||||||
if filename not in self.tasks:
|
if filename not in self.tasks:
|
||||||
self.tasks[filename] = {
|
self.tasks[filename] = {
|
||||||
'status': 'pending',
|
'status': 'pending',
|
||||||
'progress': 0,
|
'progress': 0,
|
||||||
|
'file_progress': 0,
|
||||||
|
'speed': '',
|
||||||
'label': label or filename
|
'label': label or filename
|
||||||
}
|
}
|
||||||
# Limita a 20 itens para não travar a tela
|
# Limita a 20 itens
|
||||||
if len(self.tasks) > 20:
|
if len(self.tasks) > 20:
|
||||||
self.tasks.popitem(last=False) # Remove o mais antigo
|
self.tasks.popitem(last=False)
|
||||||
|
|
||||||
# Atualiza dados
|
# Atualiza dados
|
||||||
self.tasks[filename]['status'] = status
|
self.tasks[filename]['status'] = status
|
||||||
self.tasks[filename]['progress'] = progress
|
self.tasks[filename]['progress'] = progress
|
||||||
|
self.tasks[filename]['file_progress'] = file_progress
|
||||||
|
self.tasks[filename]['speed'] = speed
|
||||||
|
|
||||||
if label:
|
if label:
|
||||||
self.tasks[filename]['label'] = label
|
self.tasks[filename]['label'] = label
|
||||||
|
|
||||||
@@ -44,6 +52,4 @@ class AppState:
|
|||||||
return list(self.logs)
|
return list(self.logs)
|
||||||
|
|
||||||
# --- INSTÂNCIA GLOBAL ---
|
# --- INSTÂNCIA GLOBAL ---
|
||||||
# Ao ser importado, isso roda uma vez e cria o objeto.
|
|
||||||
# Todo mundo que fizer 'from core.state import state' vai pegar essa mesma instância.
|
|
||||||
state = AppState()
|
state = AppState()
|
||||||
@@ -15,40 +15,29 @@ class DirectoryWatcher:
|
|||||||
def __init__(self, bot: TelegramManager):
|
def __init__(self, bot: TelegramManager):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.renamer = RenamerCore()
|
self.renamer = RenamerCore()
|
||||||
|
|
||||||
# Inicia pausado (True só quando ativado no Dashboard)
|
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
|
|
||||||
self.temp_dir = Path('/app/temp')
|
self.temp_dir = Path('/app/temp')
|
||||||
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self.current_watch_path = None
|
self.current_watch_path = None
|
||||||
|
|
||||||
# Controle de Processo
|
|
||||||
self.current_process = None
|
self.current_process = None
|
||||||
self.pending_future = None
|
self.pending_future = None
|
||||||
self.abort_flag = False
|
self.abort_flag = False
|
||||||
|
|
||||||
state.watcher = self
|
state.watcher = self
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""Inicia o loop do serviço"""
|
|
||||||
state.log("🟡 Watcher Service: Pronto. Aguardando ativação no Dashboard...")
|
state.log("🟡 Watcher Service: Pronto. Aguardando ativação no Dashboard...")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if self.is_running:
|
if self.is_running:
|
||||||
try:
|
try:
|
||||||
config_path = AppConfig.get_val('monitor_path', '/downloads')
|
config_path = AppConfig.get_val('monitor_path', '/downloads')
|
||||||
watch_dir = Path(config_path)
|
watch_dir = Path(config_path)
|
||||||
|
|
||||||
if str(watch_dir) != str(self.current_watch_path):
|
if str(watch_dir) != str(self.current_watch_path):
|
||||||
state.log(f"📁 Monitorando: {watch_dir}")
|
state.log(f"📁 Monitorando: {watch_dir}")
|
||||||
self.current_watch_path = watch_dir
|
self.current_watch_path = watch_dir
|
||||||
|
|
||||||
if watch_dir.exists():
|
if watch_dir.exists():
|
||||||
await self.scan_folder(watch_dir)
|
await self.scan_folder(watch_dir)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
state.log(f"❌ Erro Watcher Loop: {e}")
|
state.log(f"❌ Erro Watcher Loop: {e}")
|
||||||
|
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
def abort_current_task(self):
|
def abort_current_task(self):
|
||||||
@@ -67,23 +56,16 @@ class DirectoryWatcher:
|
|||||||
if state.current_file:
|
if state.current_file:
|
||||||
state.update_task(state.current_file, 'error', label=f"{state.current_file} (Cancelado)")
|
state.update_task(state.current_file, 'error', label=f"{state.current_file} (Cancelado)")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.is_running: return
|
if not self.is_running: return
|
||||||
|
|
||||||
if file_path.is_file() and file_path.suffix.lower() in VIDEO_EXTENSIONS:
|
if file_path.is_file() and file_path.suffix.lower() in VIDEO_EXTENSIONS:
|
||||||
if file_path.name.startswith('.') or 'processing' in file_path.name: continue
|
if file_path.name.startswith('.') or 'processing' in file_path.name: continue
|
||||||
|
if file_path.name in state.tasks and state.tasks[file_path.name]['status'] == 'done': continue
|
||||||
# Ignora se já terminou nesta sessão
|
|
||||||
if file_path.name in state.tasks and state.tasks[file_path.name]['status'] == 'done':
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
s1 = file_path.stat().st_size
|
s1 = file_path.stat().st_size
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
s2 = file_path.stat().st_size
|
s2 = file_path.stat().st_size
|
||||||
if s1 != s2: continue
|
if s1 != s2: continue
|
||||||
except: continue
|
except: continue
|
||||||
|
|
||||||
await self.process_pipeline(file_path)
|
await self.process_pipeline(file_path)
|
||||||
if self.abort_flag: return
|
if self.abort_flag: return
|
||||||
|
|
||||||
@@ -92,23 +74,28 @@ class DirectoryWatcher:
|
|||||||
self.abort_flag = False
|
self.abort_flag = False
|
||||||
state.current_file = fname
|
state.current_file = fname
|
||||||
|
|
||||||
state.update_task(fname, 'running', 0, label=f"Identificando: {fname}...")
|
# Etapa 1: Identificação (Global 0-15%)
|
||||||
|
state.update_task(fname, 'running', progress=5, label=f"Identificando: {fname}...")
|
||||||
state.log(f"🔄 Iniciando: {fname}")
|
state.log(f"🔄 Iniciando: {fname}")
|
||||||
|
|
||||||
# 1. IDENTIFICAÇÃO
|
|
||||||
result = self.renamer.identify_file(str(filepath))
|
result = self.renamer.identify_file(str(filepath))
|
||||||
target_info = None
|
|
||||||
is_semi_auto = AppConfig.get_val('semi_auto', 'false') == 'true'
|
is_semi_auto = AppConfig.get_val('semi_auto', 'false') == 'true'
|
||||||
|
if is_semi_auto and result['status'] == 'MATCH':
|
||||||
if is_semi_auto:
|
|
||||||
result['status'] = 'AMBIGUOUS'
|
result['status'] = 'AMBIGUOUS'
|
||||||
if 'match' in result: result['candidates'] = [result['match']]
|
result['candidates'] = [result['match']]
|
||||||
|
|
||||||
|
target_info = None
|
||||||
|
|
||||||
if result['status'] == 'MATCH':
|
if result['status'] == 'MATCH':
|
||||||
target_info = {'tmdb_id': result['match']['tmdb_id'], 'type': result['match']['type']}
|
target_info = {'tmdb_id': result['match']['tmdb_id'], 'type': result['match']['type']}
|
||||||
state.update_task(fname, 'running', 10, label=f"ID: {result['match']['title']}")
|
state.update_task(fname, 'running', progress=10, label=f"ID: {result['match']['title']}")
|
||||||
|
|
||||||
elif result['status'] == 'AMBIGUOUS':
|
elif result['status'] == 'AMBIGUOUS':
|
||||||
|
if 'candidates' not in result or not result['candidates']:
|
||||||
|
state.update_task(fname, 'error', 0, label="Erro: Ambíguo sem candidatos")
|
||||||
|
return
|
||||||
|
|
||||||
state.update_task(fname, 'warning', 10, label="Aguardando Telegram...")
|
state.update_task(fname, 'warning', 10, label="Aguardando Telegram...")
|
||||||
self.pending_future = asyncio.ensure_future(
|
self.pending_future = asyncio.ensure_future(
|
||||||
self.bot.ask_user_choice(fname, result['candidates'])
|
self.bot.ask_user_choice(fname, result['candidates'])
|
||||||
@@ -123,67 +110,104 @@ class DirectoryWatcher:
|
|||||||
if not user_choice or self.abort_flag:
|
if not user_choice or self.abort_flag:
|
||||||
state.update_task(fname, 'skipped', 0, label="Ignorado/Cancelado")
|
state.update_task(fname, 'skipped', 0, label="Ignorado/Cancelado")
|
||||||
return
|
return
|
||||||
|
|
||||||
target_info = user_choice
|
target_info = user_choice
|
||||||
else:
|
else:
|
||||||
|
msg = result.get('msg', 'Desconhecido')
|
||||||
state.update_task(fname, 'error', 0, label="Não Identificado")
|
state.update_task(fname, 'error', 0, label="Não Identificado")
|
||||||
state.log(f"❌ Falha TMDb: {result.get('msg', 'Desconhecido')}")
|
state.log(f"❌ Falha Identificação: {msg}")
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.abort_flag: return
|
if self.abort_flag: return
|
||||||
|
|
||||||
# 2. CATEGORIA
|
# Etapa 2: Categoria
|
||||||
category = self.find_category(target_info['type'])
|
category = self.find_category(target_info['type'])
|
||||||
if not category:
|
if not category:
|
||||||
state.update_task(fname, 'error', 0, label="Sem Categoria")
|
state.update_task(fname, 'error', 0, label="Sem Categoria")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 3. CONVERSÃO & CAMINHO
|
# Etapa 3: Conversão
|
||||||
try:
|
try:
|
||||||
# Recupera dados completos para montar o nome
|
|
||||||
details = self.renamer.get_details(target_info['tmdb_id'], target_info['type'])
|
details = self.renamer.get_details(target_info['tmdb_id'], target_info['type'])
|
||||||
|
r_title = getattr(details, 'title', getattr(details, 'name', 'Unknown')) or 'Unknown'
|
||||||
|
|
||||||
full_details = {
|
full_details = {'title': r_title, 'year': '0000', 'type': target_info['type']}
|
||||||
'title': getattr(details, 'title', getattr(details, 'name', 'Unknown')),
|
|
||||||
'year': '0000',
|
|
||||||
'type': target_info['type']
|
|
||||||
}
|
|
||||||
d_date = getattr(details, 'release_date', getattr(details, 'first_air_date', '0000'))
|
d_date = getattr(details, 'release_date', getattr(details, 'first_air_date', '0000'))
|
||||||
if d_date: full_details['year'] = d_date[:4]
|
if d_date: full_details['year'] = str(d_date)[:4]
|
||||||
|
|
||||||
# Gera caminho inteligente
|
|
||||||
guessed_data = result.get('guessed', {})
|
guessed_data = result.get('guessed', {})
|
||||||
relative_path = self.renamer.build_path(category, full_details, guessed_data)
|
relative_path = self.renamer.build_path(category, full_details, guessed_data)
|
||||||
|
|
||||||
temp_filename = os.path.basename(relative_path)
|
temp_filename = os.path.basename(relative_path)
|
||||||
temp_output = self.temp_dir / temp_filename
|
temp_output = self.temp_dir / temp_filename
|
||||||
|
|
||||||
state.update_task(fname, 'running', 15, label=f"Convertendo: {full_details['title']}")
|
state.update_task(fname, 'running', progress=15, label=f"Convertendo: {full_details['title']}")
|
||||||
|
|
||||||
engine = FFmpegEngine()
|
engine = FFmpegEngine()
|
||||||
total_duration = engine.get_duration(str(filepath))
|
|
||||||
|
# Tenta pegar duração total em Segundos
|
||||||
|
total_duration_sec = engine.get_duration(str(filepath))
|
||||||
|
# Converte para microsegundos para bater com o FFmpeg
|
||||||
|
total_duration_us = total_duration_sec * 1000000
|
||||||
|
|
||||||
cmd = engine.build_command(str(filepath), str(temp_output))
|
cmd = engine.build_command(str(filepath), str(temp_output))
|
||||||
|
|
||||||
|
# --- MODIFICAÇÃO: Injeta flag para saída legível por máquina (stdout) ---
|
||||||
|
# Isso garante que teremos dados de progresso confiáveis
|
||||||
|
cmd.insert(1, '-progress')
|
||||||
|
cmd.insert(2, 'pipe:1')
|
||||||
|
|
||||||
|
# Redireciona stdout para pegarmos os dados. Stderr fica para erros/warnings.
|
||||||
self.current_process = await asyncio.create_subprocess_exec(
|
self.current_process = await asyncio.create_subprocess_exec(
|
||||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- LOOP DE PROGRESSO (MÉTODO KEY=VALUE) ---
|
||||||
|
current_speed_str = ""
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if self.abort_flag:
|
if self.abort_flag:
|
||||||
self.current_process.kill()
|
self.current_process.kill()
|
||||||
break
|
break
|
||||||
line_bytes = await self.current_process.stderr.readline()
|
|
||||||
|
# Lê stdout (progresso)
|
||||||
|
line_bytes = await self.current_process.stdout.readline()
|
||||||
if not line_bytes: break
|
if not line_bytes: break
|
||||||
|
|
||||||
line = line_bytes.decode('utf-8', errors='ignore')
|
line = line_bytes.decode('utf-8', errors='ignore').strip()
|
||||||
time_match = re.search(r'time=(\d{2}):(\d{2}):(\d{2})', line)
|
|
||||||
if time_match and total_duration > 0:
|
# O formato é chave=valor
|
||||||
h, m, s = map(int, time_match.groups())
|
if '=' in line:
|
||||||
current_seconds = h*3600 + m*60 + s
|
key, value = line.split('=', 1)
|
||||||
pct = 15 + ((current_seconds / total_duration) * 80)
|
key = key.strip()
|
||||||
state.update_task(fname, 'running', pct)
|
value = value.strip()
|
||||||
|
|
||||||
|
# 1. Tempo decorrido (em microsegundos)
|
||||||
|
if key == 'out_time_us':
|
||||||
|
try:
|
||||||
|
current_us = int(value)
|
||||||
|
if total_duration_us > 0:
|
||||||
|
file_pct = (current_us / total_duration_us) * 100
|
||||||
|
if file_pct > 100: file_pct = 100
|
||||||
|
if file_pct < 0: file_pct = 0 # As vezes vem negativo no inicio
|
||||||
|
|
||||||
|
# Global: 15% a 99%
|
||||||
|
global_pct = 15 + (file_pct * 0.84)
|
||||||
|
|
||||||
|
state.update_task(
|
||||||
|
fname, 'running',
|
||||||
|
progress=global_pct,
|
||||||
|
file_progress=file_pct,
|
||||||
|
speed=current_speed_str,
|
||||||
|
label=f"Processando: {full_details['title']}"
|
||||||
|
)
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# 2. Velocidade
|
||||||
|
elif key == 'speed':
|
||||||
|
current_speed_str = value
|
||||||
|
|
||||||
await self.current_process.wait()
|
await self.current_process.wait()
|
||||||
|
# -----------------------------------
|
||||||
|
|
||||||
if self.abort_flag:
|
if self.abort_flag:
|
||||||
state.update_task(fname, 'error', 0, label="Abortado")
|
state.update_task(fname, 'error', 0, label="Abortado")
|
||||||
@@ -191,36 +215,32 @@ class DirectoryWatcher:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if self.current_process.returncode != 0:
|
if self.current_process.returncode != 0:
|
||||||
|
# Se falhar, lemos o stderr para saber o motivo
|
||||||
|
err_log = await self.current_process.stderr.read()
|
||||||
|
state.log(f"❌ Erro FFmpeg: {err_log.decode('utf-8')[-200:]}")
|
||||||
state.update_task(fname, 'error', 0, label="Erro FFmpeg")
|
state.update_task(fname, 'error', 0, label="Erro FFmpeg")
|
||||||
return
|
return
|
||||||
self.current_process = None
|
self.current_process = None
|
||||||
|
|
||||||
# 4. DEPLOY SEGURO
|
# Etapa 4: Deploy
|
||||||
state.update_task(fname, 'running', 98, label="Organizando...")
|
state.update_task(fname, 'running', progress=99, label="Organizando...")
|
||||||
|
|
||||||
# Monta caminho completo
|
|
||||||
final_full_path = Path(category.target_path) / relative_path
|
final_full_path = Path(category.target_path) / relative_path
|
||||||
|
|
||||||
# Garante que a PASTA existe (Merge seguro)
|
|
||||||
# Se a pasta já existe, o mkdir(exist_ok=True) não faz nada (não apaga, não mexe)
|
|
||||||
final_full_path.parent.mkdir(parents=True, exist_ok=True)
|
final_full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Tratamento de ARQUIVO duplicado
|
|
||||||
if final_full_path.exists():
|
if final_full_path.exists():
|
||||||
# Se o arquivo já existe, apagamos ele para substituir pelo novo (Upgrade)
|
try: os.remove(str(final_full_path))
|
||||||
state.log(f"⚠️ Substituindo arquivo existente: {final_full_path.name}")
|
except: pass
|
||||||
os.remove(str(final_full_path))
|
|
||||||
|
|
||||||
# Move APENAS o arquivo
|
|
||||||
shutil.move(str(temp_output), str(final_full_path))
|
shutil.move(str(temp_output), str(final_full_path))
|
||||||
|
|
||||||
if AppConfig.get_val('deploy_mode', 'move') == 'move':
|
if AppConfig.get_val('deploy_mode', 'move') == 'move':
|
||||||
os.remove(str(filepath))
|
try: os.remove(str(filepath))
|
||||||
|
except: pass
|
||||||
|
|
||||||
await self.bot.send_notification(f"🎬 Organizado: `{full_details['title']}`")
|
await self.bot.send_notification(f"🎬 Organizado: `{full_details['title']}`\n📂 {category.name}")
|
||||||
state.update_task(fname, 'done', 100, label=f"{full_details['title']}")
|
state.update_task(fname, 'done', 100, label=f"{full_details['title']}", file_progress=100, speed="Finalizado")
|
||||||
state.current_file = ""
|
state.current_file = ""
|
||||||
# Limpeza pasta vazia
|
|
||||||
if AppConfig.get_val('cleanup_empty_folders', 'true') == 'true':
|
if AppConfig.get_val('cleanup_empty_folders', 'true') == 'true':
|
||||||
try:
|
try:
|
||||||
parent = filepath.parent
|
parent = filepath.parent
|
||||||
@@ -234,20 +254,11 @@ class DirectoryWatcher:
|
|||||||
state.update_task(fname, 'error', 0, label=f"Erro: {e}")
|
state.update_task(fname, 'error', 0, label=f"Erro: {e}")
|
||||||
|
|
||||||
def find_category(self, media_type):
|
def find_category(self, media_type):
|
||||||
"""Encontra a categoria correta baseada nas keywords"""
|
|
||||||
# Define keywords esperadas
|
|
||||||
keywords = ['movie', 'film', 'filme'] if media_type == 'movie' else ['tv', 'serie', 'série']
|
keywords = ['movie', 'film', 'filme'] if media_type == 'movie' else ['tv', 'serie', 'série']
|
||||||
|
|
||||||
all_cats = list(Category.select())
|
all_cats = list(Category.select())
|
||||||
for cat in all_cats:
|
for cat in all_cats:
|
||||||
if not cat.match_keywords: continue
|
if not cat.match_keywords: continue
|
||||||
|
|
||||||
# --- AQUI ESTAVA O ERRO POTENCIAL ---
|
|
||||||
# O .strip() precisa dos parênteses antes do .lower()
|
|
||||||
cat_keys = [k.strip().lower() for k in cat.match_keywords.split(',')]
|
cat_keys = [k.strip().lower() for k in cat.match_keywords.split(',')]
|
||||||
|
|
||||||
if any(k in cat_keys for k in keywords):
|
if any(k in cat_keys for k in keywords):
|
||||||
return cat
|
return cat
|
||||||
|
|
||||||
# Fallback (primeira categoria que existir)
|
|
||||||
return all_cats[0] if all_cats else None
|
return all_cats[0] if all_cats else None
|
||||||
Binary file not shown.
@@ -45,33 +45,32 @@ def show():
|
|||||||
log_content = ui.label().style('white-space: pre-wrap; font-family: monospace;')
|
log_content = ui.label().style('white-space: pre-wrap; font-family: monospace;')
|
||||||
with log_container: log_content.move(log_container)
|
with log_container: log_content.move(log_container)
|
||||||
|
|
||||||
# --- COLUNA DA DIREITA: LISTA DE TAREFAS (Igual ao antigo) ---
|
# --- COLUNA DA DIREITA: LISTA DE TAREFAS ---
|
||||||
with ui.card().classes('w-full h-[80vh] bg-gray-50 flex flex-col p-0'):
|
with ui.card().classes('w-full h-[80vh] bg-gray-50 flex flex-col p-0'):
|
||||||
# Cabeçalho da Lista
|
# Cabeçalho da Lista
|
||||||
with ui.row().classes('w-full p-4 bg-white border-b items-center justify-between'):
|
with ui.row().classes('w-full p-4 bg-white border-b items-center justify-between'):
|
||||||
ui.label('📋 Fila de Processamento').classes('text-lg font-bold text-gray-700')
|
ui.label('📋 Fila de Processamento').classes('text-lg font-bold text-gray-700')
|
||||||
lbl_status_top = ui.label('Ocioso').classes('text-sm text-gray-400')
|
lbl_status_top = ui.label('Ocioso').classes('text-sm text-gray-400')
|
||||||
|
|
||||||
# Container da Lista (Onde a mágica acontece)
|
# Container da Lista
|
||||||
tasks_container = ui.column().classes('w-full p-2 gap-2 overflow-y-auto flex-grow')
|
tasks_container = ui.column().classes('w-full p-2 gap-2 overflow-y-auto flex-grow')
|
||||||
|
|
||||||
# --- RENDERIZADOR DA LISTA ---
|
# --- RENDERIZADOR DA LISTA (Com Duas Barras) ---
|
||||||
def render_tasks():
|
def render_tasks():
|
||||||
tasks_container.clear()
|
tasks_container.clear()
|
||||||
|
|
||||||
# Se não tiver tarefas
|
|
||||||
if not state.tasks:
|
if not state.tasks:
|
||||||
with tasks_container:
|
with tasks_container:
|
||||||
ui.label('Nenhuma atividade recente.').classes('text-gray-400 italic p-4')
|
ui.label('Nenhuma atividade recente.').classes('text-gray-400 italic p-4')
|
||||||
return
|
return
|
||||||
|
|
||||||
# Itera sobre as tarefas (reversed para mais recentes no topo)
|
|
||||||
for fname, data in reversed(state.tasks.items()):
|
for fname, data in reversed(state.tasks.items()):
|
||||||
status = data['status']
|
status = data['status']
|
||||||
pct = data['progress']
|
global_pct = data['progress']
|
||||||
|
file_pct = data.get('file_progress', 0)
|
||||||
|
speed = data.get('speed', '')
|
||||||
label = data['label']
|
label = data['label']
|
||||||
|
|
||||||
# Estilo baseado no status (Igual ao seu código antigo)
|
|
||||||
icon = 'circle'; color = 'grey'; spin = False
|
icon = 'circle'; color = 'grey'; spin = False
|
||||||
bg_color = 'bg-white'
|
bg_color = 'bg-white'
|
||||||
|
|
||||||
@@ -91,35 +90,52 @@ def show():
|
|||||||
icon = 'block'; color = 'red'
|
icon = 'block'; color = 'red'
|
||||||
|
|
||||||
with tasks_container:
|
with tasks_container:
|
||||||
with ui.card().classes(f'w-full p-2 {bg_color} flex-row items-center gap-3'):
|
with ui.card().classes(f'w-full p-3 {bg_color} flex-row items-start gap-3'):
|
||||||
# Ícone
|
# Ícone
|
||||||
if spin: ui.spinner(size='sm').classes('text-blue-500')
|
if spin: ui.spinner(size='sm').classes('text-blue-500 mt-1')
|
||||||
else: ui.icon(icon, color=color, size='sm')
|
else: ui.icon(icon, color=color, size='sm').classes('mt-1')
|
||||||
|
|
||||||
# Conteúdo
|
# Conteúdo
|
||||||
with ui.column().classes('flex-grow gap-0'):
|
with ui.column().classes('flex-grow gap-1 w-full'):
|
||||||
ui.label(label).classes('font-bold text-sm text-gray-800 truncate')
|
# Título e Nome Arquivo
|
||||||
ui.label(fname).classes('text-xs text-gray-500 truncate')
|
ui.label(label).classes('font-bold text-sm text-gray-800 break-all')
|
||||||
|
ui.label(fname).classes('text-xs text-gray-500 break-all mb-1')
|
||||||
|
|
||||||
# Barra de Progresso (Só aparece se estiver rodando)
|
# Se estiver rodando, mostra barras
|
||||||
if status == 'running':
|
if status == 'running':
|
||||||
with ui.row().classes('w-full items-center gap-2 mt-1'):
|
# Barra 1: Global
|
||||||
ui.linear_progress(value=pct/100, show_value=False).classes('h-2 rounded flex-grow')
|
with ui.row().classes('w-full items-center gap-2'):
|
||||||
ui.label(f"{int(pct)}%").classes('text-xs font-bold text-blue-600')
|
ui.label('Total').classes('text-xs font-bold text-gray-400 w-12')
|
||||||
|
ui.linear_progress(value=global_pct/100, show_value=False).classes('h-2 rounded flex-grow').props('color=blue-4')
|
||||||
|
ui.label(f"{int(global_pct)}%").classes('text-xs font-bold text-blue-400 w-8 text-right')
|
||||||
|
|
||||||
|
# Barra 2: Conversão de Arquivo
|
||||||
|
# Mostra se tivermos algum progresso de arquivo OU velocidade detectada
|
||||||
|
if file_pct > 0 or (speed and speed != "0x"):
|
||||||
|
with ui.row().classes('w-full items-center gap-2'):
|
||||||
|
ui.label('File').classes('text-xs font-bold text-gray-400 w-12')
|
||||||
|
|
||||||
|
# Barra Verde
|
||||||
|
ui.linear_progress(value=file_pct/100, show_value=False).classes('h-3 rounded flex-grow').props('color=green')
|
||||||
|
|
||||||
|
# Porcentagem e Velocidade
|
||||||
|
with ui.row().classes('items-center gap-1'):
|
||||||
|
ui.label(f"{int(file_pct)}%").classes('text-xs font-bold text-green-600')
|
||||||
|
if speed:
|
||||||
|
# Badge de Velocidade
|
||||||
|
ui.label(speed).classes('text-xs bg-green-100 text-green-800 px-1 rounded font-mono')
|
||||||
|
|
||||||
# --- LOOP DE ATUALIZAÇÃO ---
|
# --- LOOP DE ATUALIZAÇÃO ---
|
||||||
def update_ui():
|
def update_ui():
|
||||||
# 1. Logs
|
# Logs
|
||||||
logs = state.get_logs()
|
logs = state.get_logs()
|
||||||
log_content.set_text("\n".join(logs[-30:]))
|
log_content.set_text("\n".join(logs[-30:]))
|
||||||
log_container.scroll_to(percent=1.0)
|
log_container.scroll_to(percent=1.0)
|
||||||
|
|
||||||
# 2. Re-renderiza a lista de tarefas
|
# Tarefas
|
||||||
# Nota: O NiceGUI é eficiente, mas para listas muito grandes seria melhor atualizar in-place.
|
|
||||||
# Como limitamos a 20 itens no state, limpar e redesenhar é rápido e seguro.
|
|
||||||
render_tasks()
|
render_tasks()
|
||||||
|
|
||||||
# 3. Controles Globais
|
# Status Global
|
||||||
if state.watcher and state.watcher.is_running:
|
if state.watcher and state.watcher.is_running:
|
||||||
lbl_status_top.text = "Serviço Rodando"
|
lbl_status_top.text = "Serviço Rodando"
|
||||||
lbl_status_top.classes(replace='text-green-500')
|
lbl_status_top.classes(replace='text-green-500')
|
||||||
@@ -129,10 +145,10 @@ def show():
|
|||||||
lbl_status_top.classes(replace='text-red-400')
|
lbl_status_top.classes(replace='text-red-400')
|
||||||
switch_run.value = False
|
switch_run.value = False
|
||||||
|
|
||||||
# 4. Botão Cancelar
|
# Botão Cancelar
|
||||||
if state.current_file:
|
if state.current_file:
|
||||||
btn_cancel.classes(remove='hidden')
|
btn_cancel.classes(remove='hidden')
|
||||||
else:
|
else:
|
||||||
btn_cancel.classes(add='hidden')
|
btn_cancel.classes(add='hidden')
|
||||||
|
|
||||||
ui.timer(1.0, update_ui) # Atualiza a cada 1 segundo
|
ui.timer(1.0, update_ui)
|
||||||
1493
projeto_completo.txt
Normal file
1493
projeto_completo.txt
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user