inicio
This commit is contained in:
0
app/core/__init__.py
Executable file
0
app/core/__init__.py
Executable file
BIN
app/core/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
app/core/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/bot.cpython-311.pyc
Normal file
BIN
app/core/__pycache__/bot.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/ffmpeg_engine.cpython-311.pyc
Normal file
BIN
app/core/__pycache__/ffmpeg_engine.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/renamer.cpython-311.pyc
Normal file
BIN
app/core/__pycache__/renamer.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/state.cpython-311.pyc
Normal file
BIN
app/core/__pycache__/state.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/watcher.cpython-311.pyc
Normal file
BIN
app/core/__pycache__/watcher.cpython-311.pyc
Normal file
Binary file not shown.
166
app/core/bot.py
Executable file
166
app/core/bot.py
Executable file
@@ -0,0 +1,166 @@
|
||||
import asyncio
|
||||
import httpx # Usamos para testar a rede antes
|
||||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from telegram.ext import ApplicationBuilder, ContextTypes, CallbackQueryHandler, CommandHandler
|
||||
from database import AppConfig
|
||||
import logging
|
||||
|
||||
# Configuração de Log
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("telegram").setLevel(logging.INFO)
|
||||
|
||||
class TelegramManager:
|
||||
def __init__(self):
|
||||
# NÃO carregamos o token aqui. Carregamos na hora de iniciar.
|
||||
self.app = None
|
||||
self.active_requests = {}
|
||||
self.is_connected = False
|
||||
|
||||
async def check_internet(self):
|
||||
"""Testa se o container tem internet antes de tentar o Telegram"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
await client.get("https://www.google.com")
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
async def start(self):
|
||||
"""Inicia o Bot"""
|
||||
# 1. Pega o Token Fresquinho do Banco
|
||||
token = AppConfig.get_val('telegram_token')
|
||||
chat_id = AppConfig.get_val('telegram_chat_id')
|
||||
|
||||
if not token:
|
||||
print("🟡 Bot: Token não configurado. Aguardando você salvar no Painel...")
|
||||
return
|
||||
|
||||
# 2. Teste de Rede Prévio
|
||||
print("🤖 Bot: Verificando conectividade...")
|
||||
if not await self.check_internet():
|
||||
print("❌ Bot: ERRO DE REDE! O container não consegue acessar a internet.")
|
||||
print(" -> Verifique DNS ou Firewall.")
|
||||
return
|
||||
|
||||
print(f"🤖 Bot: Conectando com token termina em ...{token[-5:]}")
|
||||
|
||||
try:
|
||||
# 3. Constroi a Aplicação
|
||||
self.app = ApplicationBuilder().token(token).build()
|
||||
|
||||
# Handlers
|
||||
self.app.add_handler(CommandHandler("start", self.cmd_start))
|
||||
self.app.add_handler(CommandHandler("id", self.cmd_id))
|
||||
self.app.add_handler(CallbackQueryHandler(self.handle_selection))
|
||||
|
||||
# Inicializa
|
||||
await self.app.initialize()
|
||||
await self.app.start()
|
||||
|
||||
# Inicia Polling (Limpa mensagens velhas acumuladas para não travar)
|
||||
await self.app.updater.start_polling(drop_pending_updates=True)
|
||||
|
||||
self.is_connected = True
|
||||
print("✅ Bot Online e Rodando!")
|
||||
|
||||
# Tenta mandar um oi se tiver chat_id
|
||||
if chat_id:
|
||||
try:
|
||||
await self.app.bot.send_message(chat_id=chat_id, text="🚀 Clei-Flow: Conexão restabelecida!")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Bot online, mas falhou ao enviar msg (Chat ID errado?): {e}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Falha Crítica no Bot: {e}")
|
||||
self.is_connected = False
|
||||
|
||||
async def stop(self):
|
||||
if self.app:
|
||||
try:
|
||||
await self.app.updater.stop()
|
||||
await self.app.stop()
|
||||
await self.app.shutdown()
|
||||
self.is_connected = False
|
||||
except: pass
|
||||
|
||||
# --- COMANDOS ---
|
||||
async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
chat_id = update.effective_chat.id
|
||||
await update.message.reply_text(f"Olá! Configurado.\nSeu Chat ID é: `{chat_id}`", parse_mode='Markdown')
|
||||
# Opcional: Salvar o Chat ID automaticamente se o usuário mandar /start
|
||||
# AppConfig.set_val('telegram_chat_id', str(chat_id))
|
||||
|
||||
async def cmd_id(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
await update.message.reply_text(f"`{update.effective_chat.id}`", parse_mode='Markdown')
|
||||
|
||||
# --- INTERAÇÃO (Renomeação) ---
|
||||
async def ask_user_choice(self, filename, candidates):
|
||||
chat_id = AppConfig.get_val('telegram_chat_id') # Pega sempre o atual
|
||||
if not chat_id or not self.is_connected:
|
||||
print("❌ Bot não pode perguntar (Sem Chat ID ou Desconectado)")
|
||||
return None
|
||||
|
||||
request_id = f"req_{filename}"
|
||||
keyboard = []
|
||||
for cand in candidates:
|
||||
# Texto do botão
|
||||
text = f"{cand['title']} ({cand['year']})"
|
||||
# Dados (ID|Tipo)
|
||||
callback_data = f"{request_id}|{cand['tmdb_id']}|{cand['type']}"
|
||||
keyboard.append([InlineKeyboardButton(text, callback_data=callback_data)])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("🚫 Ignorar", callback_data=f"{request_id}|IGNORE|NONE")])
|
||||
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||
|
||||
try:
|
||||
await self.app.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=f"🤔 <b>Clei-Flow Precisa de Ajuda:</b>\nArquivo: <code>{filename}</code>",
|
||||
reply_markup=reply_markup,
|
||||
parse_mode='HTML'
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Erro ao enviar pergunta: {e}")
|
||||
return None
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.create_future()
|
||||
self.active_requests[request_id] = future
|
||||
|
||||
try:
|
||||
# Espera 12 horas
|
||||
result = await asyncio.wait_for(future, timeout=43200)
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
if request_id in self.active_requests: del self.active_requests[request_id]
|
||||
return None
|
||||
|
||||
async def handle_selection(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
|
||||
data = query.data.split('|')
|
||||
if len(data) < 3: return
|
||||
|
||||
req_id = data[0]
|
||||
tmdb_id = data[1]
|
||||
media_type = data[2]
|
||||
|
||||
if req_id in self.active_requests:
|
||||
future = self.active_requests[req_id]
|
||||
if tmdb_id == 'IGNORE':
|
||||
await query.edit_message_text(text=f"🚫 Ignorado.")
|
||||
future.set_result(None)
|
||||
else:
|
||||
await query.edit_message_text(text=f"✅ Processando...")
|
||||
future.set_result({'tmdb_id': int(tmdb_id), 'type': media_type})
|
||||
del self.active_requests[req_id]
|
||||
else:
|
||||
await query.edit_message_text(text="⚠️ Solicitação expirada.")
|
||||
|
||||
async def send_notification(self, message):
|
||||
chat_id = AppConfig.get_val('telegram_chat_id')
|
||||
if self.app and chat_id and self.is_connected:
|
||||
try:
|
||||
await self.app.bot.send_message(chat_id=chat_id, text=message)
|
||||
except: pass
|
||||
121
app/core/ffmpeg_engine.py
Executable file
121
app/core/ffmpeg_engine.py
Executable file
@@ -0,0 +1,121 @@
|
||||
import subprocess
|
||||
import json
|
||||
from database import FFmpegProfile
|
||||
from core.state import state
|
||||
|
||||
class FFmpegEngine:
|
||||
def __init__(self, profile_id=None):
|
||||
if profile_id:
|
||||
self.profile = FFmpegProfile.get_by_id(profile_id)
|
||||
else:
|
||||
self.profile = FFmpegProfile.get_or_none(FFmpegProfile.is_active == True)
|
||||
|
||||
if not self.profile:
|
||||
state.log("⚠️ Nenhum perfil FFmpeg ativo!")
|
||||
|
||||
def get_file_info(self, filepath):
|
||||
cmd = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', filepath]
|
||||
try:
|
||||
output = subprocess.check_output(cmd).decode('utf-8')
|
||||
return json.loads(output)
|
||||
except: return None
|
||||
|
||||
def get_duration(self, filepath):
|
||||
try: return float(self.get_file_info(filepath)['format']['duration'])
|
||||
except: return 0
|
||||
|
||||
def build_command(self, input_file, output_file):
|
||||
if not self.profile: raise Exception("Perfil não selecionado")
|
||||
p = self.profile
|
||||
metadata = self.get_file_info(input_file)
|
||||
if not metadata: raise Exception("Metadados inválidos")
|
||||
|
||||
cmd = ['ffmpeg', '-y']
|
||||
|
||||
# --- HARDWARE INIT ---
|
||||
# VAAPI (Intel Linux/Docker) - O Jeito Correto
|
||||
if 'vaapi' in p.video_codec:
|
||||
cmd.extend(['-init_hw_device', 'vaapi=va:/dev/dri/renderD128'])
|
||||
cmd.extend(['-hwaccel', 'vaapi', '-hwaccel_output_format', 'vaapi', '-hwaccel_device', 'va'])
|
||||
cmd.extend(['-i', input_file])
|
||||
# Filtro essencial para VAAPI: garante formato NV12 na GPU
|
||||
# Mas como usamos hwaccel_output_format vaapi, o filtro pode ser simplificado ou scale_vaapi
|
||||
# Vamos usar o padrão seguro que funciona em Haswell:
|
||||
video_filters = 'format=nv12,hwupload'
|
||||
|
||||
elif 'qsv' in p.video_codec:
|
||||
cmd.extend(['-hwaccel', 'qsv', '-i', input_file])
|
||||
video_filters = None
|
||||
|
||||
elif 'nvenc' in p.video_codec:
|
||||
cmd.extend(['-hwaccel', 'cuda', '-i', input_file])
|
||||
video_filters = None
|
||||
|
||||
else:
|
||||
# CPU
|
||||
cmd.extend(['-i', input_file])
|
||||
video_filters = None
|
||||
|
||||
# --- VÍDEO ---
|
||||
cmd.extend(['-map', '0:v:0'])
|
||||
|
||||
if p.video_codec == 'copy':
|
||||
cmd.extend(['-c:v', 'copy'])
|
||||
else:
|
||||
cmd.extend(['-c:v', p.video_codec])
|
||||
|
||||
# Se tem filtro de hardware (VAAPI precisa subir pra GPU se hwaccel falhar no decode)
|
||||
if 'vaapi' in p.video_codec:
|
||||
# Se usarmos -hwaccel vaapi, o stream ja esta na GPU.
|
||||
# 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
|
||||
if 'vaapi' in p.video_codec:
|
||||
# VAAPI usa QP, não CRF padrão
|
||||
# Se der erro, troque '-qp' por '-rc_mode CQP -global_quality'
|
||||
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:
|
||||
cmd.extend(['-cq', str(p.crf), '-preset', p.preset])
|
||||
|
||||
elif 'libx264' in p.video_codec:
|
||||
cmd.extend(['-crf', str(p.crf), '-preset', p.preset])
|
||||
|
||||
# --- ÁUDIO ---
|
||||
allowed = p.audio_langs.split(',') if p.audio_langs else []
|
||||
audio_streams = [s for s in metadata['streams'] if s['codec_type'] == 'audio']
|
||||
acount = 0
|
||||
for s in audio_streams:
|
||||
l = s.get('tags', {}).get('language', 'und')
|
||||
if not allowed or l in allowed or 'und' in allowed:
|
||||
cmd.extend(['-map', f'0:{s["index"]}'])
|
||||
cmd.extend([f'-c:a:{acount}', 'aac', f'-b:a:{acount}', '192k'])
|
||||
acount += 1
|
||||
if acount == 0: cmd.extend(['-map', '0:a:0', '-c:a', 'aac'])
|
||||
|
||||
# --- LEGENDAS ---
|
||||
lallowed = p.subtitle_langs.split(',') if p.subtitle_langs else []
|
||||
sub_streams = [s for s in metadata['streams'] if s['codec_type'] == 'subtitle']
|
||||
scount = 0
|
||||
for s in sub_streams:
|
||||
l = s.get('tags', {}).get('language', 'und')
|
||||
if not lallowed or l in lallowed or 'und' in lallowed:
|
||||
cmd.extend(['-map', f'0:{s["index"]}'])
|
||||
cmd.extend([f'-c:s:{scount}', 'copy'])
|
||||
scount += 1
|
||||
|
||||
cmd.extend(['-metadata', 'title=', '-metadata', 'comment=CleiFlow'])
|
||||
cmd.append(output_file)
|
||||
|
||||
# LOG DO COMANDO PARA DEBUG
|
||||
state.log(f"🛠️ CMD: {' '.join(cmd)}")
|
||||
|
||||
return cmd
|
||||
15
app/core/flow.py
Executable file
15
app/core/flow.py
Executable file
@@ -0,0 +1,15 @@
|
||||
from .renamer import RenamerCore
|
||||
from .ffmpeg_engine import FFmpegCore
|
||||
from .bot import BotCore
|
||||
|
||||
class CleiFlow:
|
||||
"""
|
||||
Gerencia o ciclo de vida do arquivo.
|
||||
Modos: Manual, Híbrido, Automático.
|
||||
"""
|
||||
def start_pipeline(self, file_path):
|
||||
# 1. Identificar
|
||||
# 2. Se ambíguo -> Chamar Bot.ask_for_decision() -> Pausar Thread
|
||||
# 3. Converter (FFmpegCore)
|
||||
# 4. Mover
|
||||
pass
|
||||
155
app/core/renamer.py
Executable file
155
app/core/renamer.py
Executable file
@@ -0,0 +1,155 @@
|
||||
import os
|
||||
import re
|
||||
from guessit import guessit
|
||||
from tmdbv3api import TMDb, Movie, TV, Search
|
||||
from database import AppConfig
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
class RenamerCore:
|
||||
def __init__(self):
|
||||
self.api_key = AppConfig.get_val('tmdb_api_key')
|
||||
self.lang = AppConfig.get_val('tmdb_language', 'pt-BR')
|
||||
self.min_confidence = int(AppConfig.get_val('min_confidence', '90')) / 100.0
|
||||
|
||||
self.tmdb = TMDb()
|
||||
if self.api_key:
|
||||
self.tmdb.api_key = self.api_key
|
||||
self.tmdb.language = self.lang
|
||||
|
||||
self.movie_api = Movie()
|
||||
self.tv_api = TV()
|
||||
self.search_api = Search()
|
||||
|
||||
def identify_file(self, filepath):
|
||||
filename = os.path.basename(filepath)
|
||||
try:
|
||||
guess = guessit(filename)
|
||||
except Exception as e:
|
||||
return {'status': 'ERROR', 'msg': str(e)}
|
||||
|
||||
title = guess.get('title')
|
||||
if not title: return {'status': 'NOT_FOUND', 'msg': 'Sem título'}
|
||||
|
||||
if not self.api_key: return {'status': 'ERROR', 'msg': 'Sem API Key'}
|
||||
|
||||
try:
|
||||
media_type = guess.get('type', 'movie')
|
||||
if media_type == 'episode':
|
||||
results = self.search_api.tv_shows(term=title)
|
||||
else:
|
||||
results = self.search_api.movies(term=title)
|
||||
if not results: results = self.search_api.tv_shows(term=title)
|
||||
except: return {'status': 'NOT_FOUND', 'msg': 'Erro TMDb'}
|
||||
|
||||
if not results: return {'status': 'NOT_FOUND', 'msg': 'Nenhum resultado TMDb'}
|
||||
|
||||
# --- CORREÇÃO DE SEGURANÇA (O erro 'str object' estava aqui) ---
|
||||
# Se o TMDb retornou um dicionário (paginado), pegamos a lista dentro dele.
|
||||
if isinstance(results, dict) and 'results' in results:
|
||||
results = results['results']
|
||||
# Se retornou um objeto que tem atributo 'results', usamos ele
|
||||
elif hasattr(results, 'results'):
|
||||
results = results.results
|
||||
|
||||
# Se ainda assim for uma lista de strings (chaves), aborta
|
||||
if results and isinstance(results, list) and len(results) > 0 and isinstance(results[0], str):
|
||||
# Isso acontece se iterou sobre chaves de um dict sem querer
|
||||
return {'status': 'NOT_FOUND', 'msg': 'Formato de resposta inválido'}
|
||||
# -------------------------------------------------------------
|
||||
|
||||
candidates = []
|
||||
for res in results:
|
||||
# Proteção extra: se o item for string, pula
|
||||
if isinstance(res, str): continue
|
||||
|
||||
# Obtém atributos de forma segura (funciona para dict ou objeto)
|
||||
if isinstance(res, dict):
|
||||
r_id = res.get('id')
|
||||
r_title = res.get('title') or res.get('name')
|
||||
r_date = res.get('release_date') or res.get('first_air_date')
|
||||
r_overview = res.get('overview', '')
|
||||
else:
|
||||
r_id = getattr(res, 'id', None)
|
||||
r_title = getattr(res, 'title', getattr(res, 'name', ''))
|
||||
r_date = getattr(res, 'release_date', getattr(res, 'first_air_date', ''))
|
||||
r_overview = getattr(res, 'overview', '')
|
||||
|
||||
if not r_title or not r_id: continue
|
||||
|
||||
r_year = int(str(r_date)[:4]) if r_date else 0
|
||||
|
||||
# Score e Comparação
|
||||
t1 = str(title).lower()
|
||||
t2 = str(r_title).lower()
|
||||
|
||||
base_score = SequenceMatcher(None, t1, t2).ratio()
|
||||
|
||||
if t1 in t2 or t2 in t1:
|
||||
base_score = max(base_score, 0.85)
|
||||
|
||||
g_year = guess.get('year')
|
||||
if g_year and r_year:
|
||||
if g_year == r_year: base_score += 0.15
|
||||
elif abs(g_year - r_year) <= 1: base_score += 0.05
|
||||
|
||||
final_score = min(base_score, 1.0)
|
||||
|
||||
candidates.append({
|
||||
'tmdb_id': r_id,
|
||||
'title': r_title,
|
||||
'year': r_year,
|
||||
'type': 'movie' if hasattr(res, 'title') or (isinstance(res, dict) and 'title' in res) else 'tv',
|
||||
'overview': str(r_overview)[:100],
|
||||
'score': final_score
|
||||
})
|
||||
|
||||
if not candidates: return {'status': 'NOT_FOUND', 'msg': 'Sem candidatos válidos'}
|
||||
|
||||
candidates.sort(key=lambda x: x['score'], reverse=True)
|
||||
best = candidates[0]
|
||||
|
||||
if len(candidates) == 1 and best['score'] > 0.6:
|
||||
return {'status': 'MATCH', 'match': best, 'guessed': guess}
|
||||
|
||||
is_clear_winner = False
|
||||
if len(candidates) > 1:
|
||||
if (best['score'] - candidates[1]['score']) > 0.15:
|
||||
is_clear_winner = True
|
||||
|
||||
if best['score'] >= self.min_confidence or is_clear_winner:
|
||||
return {'status': 'MATCH', 'match': best, 'guessed': guess}
|
||||
|
||||
return {'status': 'AMBIGUOUS', 'candidates': candidates[:5], 'guessed': guess}
|
||||
|
||||
def get_details(self, tmdb_id, media_type):
|
||||
if media_type == 'movie': return self.movie_api.details(tmdb_id)
|
||||
return self.tv_api.details(tmdb_id)
|
||||
|
||||
def build_path(self, category_obj, media_info, guessed_info):
|
||||
clean_title = re.sub(r'[\\/*?:"<>|]', "", media_info['title']).strip()
|
||||
year = str(media_info['year'])
|
||||
|
||||
forced_type = category_obj.content_type
|
||||
actual_type = media_info['type']
|
||||
|
||||
is_series = False
|
||||
if forced_type == 'series': is_series = True
|
||||
elif forced_type == 'movie': is_series = False
|
||||
else: is_series = (actual_type == 'tv')
|
||||
|
||||
if not is_series:
|
||||
return f"{clean_title} ({year}).mkv"
|
||||
else:
|
||||
season = guessed_info.get('season')
|
||||
episode = guessed_info.get('episode')
|
||||
|
||||
if isinstance(season, list): season = season[0]
|
||||
if isinstance(episode, list): episode = episode[0]
|
||||
|
||||
if not season: season = 1
|
||||
if not episode: episode = 1
|
||||
|
||||
season_folder = f"Temporada {int(season):02d}"
|
||||
file_suffix = f"S{int(season):02d}E{int(episode):02d}"
|
||||
|
||||
return os.path.join(clean_title, season_folder, f"{clean_title} {file_suffix}.mkv")
|
||||
49
app/core/state.py
Normal file
49
app/core/state.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from collections import deque, OrderedDict
|
||||
|
||||
class AppState:
|
||||
def __init__(self):
|
||||
# --- Logs do Sistema ---
|
||||
self.logs = deque(maxlen=1000)
|
||||
|
||||
# --- Referência ao Watcher ---
|
||||
self.watcher = None
|
||||
|
||||
# --- Lista de Tarefas (Visualização tipo Árvore/Lista) ---
|
||||
self.tasks = OrderedDict()
|
||||
|
||||
# --- Variáveis de Estado (Compatibilidade) ---
|
||||
self.current_file = ""
|
||||
self.progress = 0.0
|
||||
self.status_text = "Aguardando..."
|
||||
|
||||
def log(self, message):
|
||||
"""Adiciona log e printa no console"""
|
||||
print(message)
|
||||
self.logs.append(message)
|
||||
|
||||
def update_task(self, filename, status, progress=0, label=None):
|
||||
"""Atualiza o status de um arquivo na interface"""
|
||||
# Se não existe, cria
|
||||
if filename not in self.tasks:
|
||||
self.tasks[filename] = {
|
||||
'status': 'pending',
|
||||
'progress': 0,
|
||||
'label': label or filename
|
||||
}
|
||||
# Limita a 20 itens para não travar a tela
|
||||
if len(self.tasks) > 20:
|
||||
self.tasks.popitem(last=False) # Remove o mais antigo
|
||||
|
||||
# Atualiza dados
|
||||
self.tasks[filename]['status'] = status
|
||||
self.tasks[filename]['progress'] = progress
|
||||
if label:
|
||||
self.tasks[filename]['label'] = label
|
||||
|
||||
def get_logs(self):
|
||||
return list(self.logs)
|
||||
|
||||
# --- 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()
|
||||
243
app/core/watcher.py
Normal file
243
app/core/watcher.py
Normal file
@@ -0,0 +1,243 @@
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import re
|
||||
from pathlib import Path
|
||||
from database import AppConfig, Category
|
||||
from core.renamer import RenamerCore
|
||||
from core.ffmpeg_engine import FFmpegEngine
|
||||
from core.bot import TelegramManager
|
||||
from core.state import state
|
||||
|
||||
VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.avi', '.mov', '.wmv'}
|
||||
|
||||
class DirectoryWatcher:
|
||||
def __init__(self, bot: TelegramManager):
|
||||
self.bot = bot
|
||||
self.renamer = RenamerCore()
|
||||
|
||||
# Inicia pausado (True só quando ativado no Dashboard)
|
||||
self.is_running = False
|
||||
|
||||
self.temp_dir = Path('/app/temp')
|
||||
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.current_watch_path = None
|
||||
|
||||
# Controle de Processo
|
||||
self.current_process = None
|
||||
self.pending_future = None
|
||||
self.abort_flag = False
|
||||
|
||||
state.watcher = self
|
||||
|
||||
async def start(self):
|
||||
"""Inicia o loop do serviço"""
|
||||
state.log("🟡 Watcher Service: Pronto. Aguardando ativação no Dashboard...")
|
||||
|
||||
while True:
|
||||
if self.is_running:
|
||||
try:
|
||||
config_path = AppConfig.get_val('monitor_path', '/downloads')
|
||||
watch_dir = Path(config_path)
|
||||
|
||||
if str(watch_dir) != str(self.current_watch_path):
|
||||
state.log(f"📁 Monitorando: {watch_dir}")
|
||||
self.current_watch_path = watch_dir
|
||||
|
||||
if watch_dir.exists():
|
||||
await self.scan_folder(watch_dir)
|
||||
except Exception as e:
|
||||
state.log(f"❌ Erro Watcher Loop: {e}")
|
||||
|
||||
await asyncio.sleep(5)
|
||||
|
||||
def abort_current_task(self):
|
||||
state.log("🛑 Solicitando Cancelamento...")
|
||||
self.abort_flag = True
|
||||
if self.current_process:
|
||||
try: self.current_process.kill()
|
||||
except: pass
|
||||
if self.pending_future and not self.pending_future.done():
|
||||
self.pending_future.cancel()
|
||||
|
||||
async def scan_folder(self, input_dir: Path):
|
||||
for file_path in input_dir.glob('**/*'):
|
||||
if self.abort_flag:
|
||||
self.abort_flag = False
|
||||
if state.current_file:
|
||||
state.update_task(state.current_file, 'error', label=f"{state.current_file} (Cancelado)")
|
||||
return
|
||||
|
||||
if not self.is_running: return
|
||||
|
||||
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
|
||||
|
||||
# Ignora se já terminou nesta sessão
|
||||
if file_path.name in state.tasks and state.tasks[file_path.name]['status'] == 'done':
|
||||
continue
|
||||
|
||||
try:
|
||||
s1 = file_path.stat().st_size
|
||||
await asyncio.sleep(1)
|
||||
s2 = file_path.stat().st_size
|
||||
if s1 != s2: continue
|
||||
except: continue
|
||||
|
||||
await self.process_pipeline(file_path)
|
||||
if self.abort_flag: return
|
||||
|
||||
async def process_pipeline(self, filepath: Path):
|
||||
fname = filepath.name
|
||||
self.abort_flag = False
|
||||
state.current_file = fname
|
||||
|
||||
state.update_task(fname, 'running', 0, label=f"Identificando: {fname}...")
|
||||
state.log(f"🔄 Iniciando: {fname}")
|
||||
|
||||
# 1. IDENTIFICAÇÃO
|
||||
result = self.renamer.identify_file(str(filepath))
|
||||
target_info = None
|
||||
is_semi_auto = AppConfig.get_val('semi_auto', 'false') == 'true'
|
||||
|
||||
if is_semi_auto:
|
||||
result['status'] = 'AMBIGUOUS'
|
||||
if 'match' in result: result['candidates'] = [result['match']]
|
||||
|
||||
if result['status'] == 'MATCH':
|
||||
target_info = {'tmdb_id': result['match']['tmdb_id'], 'type': result['match']['type']}
|
||||
state.update_task(fname, 'running', 10, label=f"ID: {result['match']['title']}")
|
||||
|
||||
elif result['status'] == 'AMBIGUOUS':
|
||||
state.update_task(fname, 'warning', 10, label="Aguardando Telegram...")
|
||||
self.pending_future = asyncio.ensure_future(
|
||||
self.bot.ask_user_choice(fname, result['candidates'])
|
||||
)
|
||||
try:
|
||||
user_choice = await self.pending_future
|
||||
except asyncio.CancelledError:
|
||||
state.update_task(fname, 'error', 0, label="Cancelado Manualmente")
|
||||
return
|
||||
|
||||
self.pending_future = None
|
||||
if not user_choice or self.abort_flag:
|
||||
state.update_task(fname, 'skipped', 0, label="Ignorado/Cancelado")
|
||||
return
|
||||
|
||||
target_info = user_choice
|
||||
else:
|
||||
state.update_task(fname, 'error', 0, label="Não Identificado")
|
||||
state.log(f"❌ Falha TMDb: {result.get('msg', 'Desconhecido')}")
|
||||
return
|
||||
|
||||
if self.abort_flag: return
|
||||
|
||||
# 2. CATEGORIA
|
||||
category = self.find_category(target_info['type'])
|
||||
if not category:
|
||||
state.update_task(fname, 'error', 0, label="Sem Categoria")
|
||||
return
|
||||
|
||||
# 3. CONVERSÃO & CAMINHO
|
||||
try:
|
||||
# Recupera dados completos para montar o nome
|
||||
details = self.renamer.get_details(target_info['tmdb_id'], target_info['type'])
|
||||
|
||||
full_details = {
|
||||
'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'))
|
||||
if d_date: full_details['year'] = d_date[:4]
|
||||
|
||||
# Gera caminho inteligente
|
||||
guessed_data = result.get('guessed', {})
|
||||
relative_path = self.renamer.build_path(category, full_details, guessed_data)
|
||||
|
||||
temp_filename = os.path.basename(relative_path)
|
||||
temp_output = self.temp_dir / temp_filename
|
||||
|
||||
state.update_task(fname, 'running', 15, label=f"Convertendo: {full_details['title']}")
|
||||
|
||||
engine = FFmpegEngine()
|
||||
total_duration = engine.get_duration(str(filepath))
|
||||
cmd = engine.build_command(str(filepath), str(temp_output))
|
||||
|
||||
self.current_process = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
while True:
|
||||
if self.abort_flag:
|
||||
self.current_process.kill()
|
||||
break
|
||||
line_bytes = await self.current_process.stderr.readline()
|
||||
if not line_bytes: break
|
||||
|
||||
line = line_bytes.decode('utf-8', errors='ignore')
|
||||
time_match = re.search(r'time=(\d{2}):(\d{2}):(\d{2})', line)
|
||||
if time_match and total_duration > 0:
|
||||
h, m, s = map(int, time_match.groups())
|
||||
current_seconds = h*3600 + m*60 + s
|
||||
pct = 15 + ((current_seconds / total_duration) * 80)
|
||||
state.update_task(fname, 'running', pct)
|
||||
|
||||
await self.current_process.wait()
|
||||
|
||||
if self.abort_flag:
|
||||
state.update_task(fname, 'error', 0, label="Abortado")
|
||||
if temp_output.exists(): os.remove(str(temp_output))
|
||||
return
|
||||
|
||||
if self.current_process.returncode != 0:
|
||||
state.update_task(fname, 'error', 0, label="Erro FFmpeg")
|
||||
return
|
||||
self.current_process = None
|
||||
|
||||
# 4. DEPLOY FINAL
|
||||
state.update_task(fname, 'running', 98, label="Organizando...")
|
||||
|
||||
final_full_path = Path(category.target_path) / relative_path
|
||||
final_full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
shutil.move(str(temp_output), str(final_full_path))
|
||||
|
||||
if AppConfig.get_val('deploy_mode', 'move') == 'move':
|
||||
os.remove(str(filepath))
|
||||
|
||||
await self.bot.send_notification(f"🎬 Organizado: `{full_details['title']}`")
|
||||
state.update_task(fname, 'done', 100, label=f"{full_details['title']}")
|
||||
state.current_file = ""
|
||||
|
||||
# Limpeza pasta vazia
|
||||
if AppConfig.get_val('cleanup_empty_folders', 'true') == 'true':
|
||||
try:
|
||||
parent = filepath.parent
|
||||
monitor_root = Path(AppConfig.get_val('monitor_path', '/downloads'))
|
||||
if parent != monitor_root and not any(parent.iterdir()):
|
||||
parent.rmdir()
|
||||
except: pass
|
||||
|
||||
except Exception as e:
|
||||
state.log(f"Erro Pipeline: {e}")
|
||||
state.update_task(fname, 'error', 0, label=f"Erro: {e}")
|
||||
|
||||
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']
|
||||
|
||||
all_cats = list(Category.select())
|
||||
for cat in all_cats:
|
||||
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(',')]
|
||||
|
||||
if any(k in cat_keys for k in keywords):
|
||||
return cat
|
||||
|
||||
# Fallback (primeira categoria que existir)
|
||||
return all_cats[0] if all_cats else None
|
||||
Reference in New Issue
Block a user