Versão Inicial V18 (FFmpeg + Haswell)
This commit is contained in:
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Ignorar arquivos de sistema e Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Ignorar dados do PyMedia Manager (Banco de dados e configs locais)
|
||||||
|
app/data/
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Ignorar pastas de downloads e vídeos
|
||||||
|
downloads/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# Ignorar arquivos de ambiente (se tiver no futuro)
|
||||||
|
.env
|
||||||
33
Dockerfile
Executable file
33
Dockerfile
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
FROM ubuntu:22.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# 1. Instalar FFmpeg e APENAS o driver i965 (Haswell)
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
# i965 é o driver correto para 4ª Geração
|
||||||
|
i965-va-driver-shaders \
|
||||||
|
libva-drm2 \
|
||||||
|
libva-x11-2 \
|
||||||
|
vainfo \
|
||||||
|
ffmpeg \
|
||||||
|
jq \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 2. GARANTIA: Remove qualquer vestígio do driver iHD se ele veio por dependência
|
||||||
|
RUN apt-get remove -y intel-media-va-driver intel-media-va-driver-non-free || true
|
||||||
|
|
||||||
|
# 3. Python Setup
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 4. Copiar código
|
||||||
|
COPY app /app
|
||||||
|
|
||||||
|
# Variável de ambiente fixa para o driver
|
||||||
|
ENV LIBVA_DRIVER_NAME=i965
|
||||||
|
|
||||||
|
CMD ["streamlit", "run", "main.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
||||||
26
app/main.py
Executable file
26
app/main.py
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
import streamlit as st
|
||||||
|
from modules import file_manager, renamer, encoder
|
||||||
|
|
||||||
|
st.set_page_config(page_title="PyMedia Manager", layout="wide", page_icon="🎬")
|
||||||
|
|
||||||
|
st.title("🎬 PyMedia Manager - Central de Controle")
|
||||||
|
|
||||||
|
# CSS para melhorar visual
|
||||||
|
st.markdown("""
|
||||||
|
<style>
|
||||||
|
.stTabs [data-baseweb="tab-list"] { gap: 24px; }
|
||||||
|
.stTabs [data-baseweb="tab"] { height: 50px; white-space: pre-wrap; background-color: #f0f2f6; border-radius: 4px 4px 0 0; gap: 1px; padding-top: 10px; padding-bottom: 10px; }
|
||||||
|
.stTabs [aria-selected="true"] { background-color: #ffffff; border-bottom: 2px solid #ff4b4b; }
|
||||||
|
</style>
|
||||||
|
""", unsafe_allow_html=True)
|
||||||
|
|
||||||
|
tab1, tab2, tab3 = st.tabs(["📂 Gerenciador de Arquivos", "🏷️ Renomeador (Séries)", "⚙️ Encoder (FFmpeg)"])
|
||||||
|
|
||||||
|
with tab1:
|
||||||
|
file_manager.render()
|
||||||
|
|
||||||
|
with tab2:
|
||||||
|
renamer.render()
|
||||||
|
|
||||||
|
with tab3:
|
||||||
|
encoder.render()
|
||||||
0
app/modules/__init__.py
Executable file
0
app/modules/__init__.py
Executable file
264
app/modules/encoder.py
Executable file
264
app/modules/encoder.py
Executable file
@@ -0,0 +1,264 @@
|
|||||||
|
import streamlit as st
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# --- CONFIGURAÇÕES ---
|
||||||
|
ROOT_DIR = "/downloads"
|
||||||
|
OUTPUT_BASE = "/downloads/finalizados"
|
||||||
|
STATUS_FILE = "/app/data/encoder_status.json"
|
||||||
|
|
||||||
|
# --- GERENCIAMENTO DE ESTADO EM DISCO ---
|
||||||
|
def save_status(data):
|
||||||
|
"""Salva o estado atual no disco para sobreviver ao F5"""
|
||||||
|
try:
|
||||||
|
with open(STATUS_FILE, 'w') as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
def load_status():
|
||||||
|
"""Lê o estado do disco"""
|
||||||
|
if not os.path.exists(STATUS_FILE):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(STATUS_FILE, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def reset_status():
|
||||||
|
"""Reseta o status para 'parado'"""
|
||||||
|
save_status({
|
||||||
|
"is_running": False,
|
||||||
|
"current_file": "",
|
||||||
|
"progress": 0,
|
||||||
|
"total_progress": 0,
|
||||||
|
"log": "Aguardando...",
|
||||||
|
"stop_requested": False
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- WORKER EM BACKGROUD ---
|
||||||
|
class BackgroundWorker(threading.Thread):
|
||||||
|
def __init__(self, input_folder, delete_orig):
|
||||||
|
super().__init__()
|
||||||
|
self.input_folder = input_folder
|
||||||
|
self.delete_orig = delete_orig
|
||||||
|
self.daemon = True # Daemon threads rodam em background independente da sessão
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
# 1. Preparação
|
||||||
|
prepare_driver_environment()
|
||||||
|
|
||||||
|
files_to_process = []
|
||||||
|
for r, d, f in os.walk(self.input_folder):
|
||||||
|
if "/finalizados" in r or "/temp" in r: continue
|
||||||
|
for file in f:
|
||||||
|
if file.lower().endswith(('.mkv', '.mp4', '.avi')):
|
||||||
|
files_to_process.append(os.path.join(r, file))
|
||||||
|
|
||||||
|
total = len(files_to_process)
|
||||||
|
|
||||||
|
# Salva estado inicial
|
||||||
|
state = {
|
||||||
|
"is_running": True,
|
||||||
|
"current_file": "Iniciando...",
|
||||||
|
"progress": 0,
|
||||||
|
"total_progress": 0,
|
||||||
|
"log": "Preparando fila...",
|
||||||
|
"stop_requested": False
|
||||||
|
}
|
||||||
|
save_status(state)
|
||||||
|
|
||||||
|
# 2. Loop de Arquivos
|
||||||
|
for i, fpath in enumerate(files_to_process):
|
||||||
|
# Verifica se pediram pra parar
|
||||||
|
current_state = load_status()
|
||||||
|
if current_state and current_state.get("stop_requested"):
|
||||||
|
break
|
||||||
|
|
||||||
|
fname = os.path.basename(fpath)
|
||||||
|
duration = get_video_duration(fpath)
|
||||||
|
|
||||||
|
# Atualiza estado para arquivo atual
|
||||||
|
state["current_file"] = fname
|
||||||
|
state["progress"] = 0
|
||||||
|
state["total_progress"] = int((i / total) * 100)
|
||||||
|
state["log"] = "Convertendo..."
|
||||||
|
save_status(state)
|
||||||
|
|
||||||
|
# Caminhos
|
||||||
|
rel_path = os.path.relpath(fpath, self.input_folder)
|
||||||
|
folder_name = os.path.basename(self.input_folder)
|
||||||
|
if self.input_folder == ROOT_DIR:
|
||||||
|
out_full_path = os.path.join(OUTPUT_BASE, rel_path)
|
||||||
|
else:
|
||||||
|
out_full_path = os.path.join(OUTPUT_BASE, folder_name, rel_path)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(out_full_path), exist_ok=True)
|
||||||
|
|
||||||
|
# Comando FFmpeg
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-hwaccel", "vaapi",
|
||||||
|
"-hwaccel_device", "/dev/dri/renderD128",
|
||||||
|
"-hwaccel_output_format", "vaapi",
|
||||||
|
"-i", fpath
|
||||||
|
]
|
||||||
|
cmd += get_streams_map(fpath)
|
||||||
|
cmd += [
|
||||||
|
"-c:v", "h264_vaapi", "-b:v", "4500k", "-compression_level", "1",
|
||||||
|
"-c:a", "copy", "-c:s", "copy",
|
||||||
|
out_full_path
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Executa
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
|
text=True, bufsize=1, universal_newlines=True, env=os.environ
|
||||||
|
)
|
||||||
|
|
||||||
|
for line in process.stdout:
|
||||||
|
# Checa stop a cada linha lida (para resposta rápida)
|
||||||
|
chk_state = load_status()
|
||||||
|
if chk_state and chk_state.get("stop_requested"):
|
||||||
|
process.terminate()
|
||||||
|
break
|
||||||
|
|
||||||
|
if "time=" in line and duration:
|
||||||
|
match = re.search(r"time=(\d{2}:\d{2}:\d{2}\.\d{2})", line)
|
||||||
|
if match:
|
||||||
|
secs = parse_time_to_seconds(match.group(1))
|
||||||
|
pct = int((secs / duration) * 100)
|
||||||
|
if pct > 100: pct = 100
|
||||||
|
|
||||||
|
# Atualiza status no disco
|
||||||
|
state["progress"] = pct
|
||||||
|
|
||||||
|
# Pega velocidade
|
||||||
|
speed_match = re.search(r"speed=\s*(\S+)", line)
|
||||||
|
if speed_match:
|
||||||
|
state["log"] = f"Velocidade: {speed_match.group(1)}"
|
||||||
|
|
||||||
|
save_status(state)
|
||||||
|
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
if process.returncode == 0:
|
||||||
|
if self.delete_orig: os.remove(fpath)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
state["log"] = f"Erro: {str(e)}"
|
||||||
|
save_status(state)
|
||||||
|
|
||||||
|
# Fim
|
||||||
|
reset_status()
|
||||||
|
|
||||||
|
# --- FUNÇÕES AUXILIARES ---
|
||||||
|
def prepare_driver_environment():
|
||||||
|
os.environ["LIBVA_DRIVER_NAME"] = "i965"
|
||||||
|
drivers_ruins = ["/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so", "/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so.1"]
|
||||||
|
for driver in drivers_ruins:
|
||||||
|
if os.path.exists(driver):
|
||||||
|
try: os.remove(driver)
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
def get_all_subfolders(root_path):
|
||||||
|
folder_list = [root_path]
|
||||||
|
try:
|
||||||
|
for root, dirs, files in os.walk(root_path):
|
||||||
|
if "finalizados" in root or "temp" in root: continue
|
||||||
|
for d in dirs:
|
||||||
|
folder_list.append(os.path.join(root, d))
|
||||||
|
except: pass
|
||||||
|
return sorted(folder_list)
|
||||||
|
|
||||||
|
def get_video_duration(filepath):
|
||||||
|
cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filepath]
|
||||||
|
try: return float(subprocess.check_output(cmd).decode().strip())
|
||||||
|
except: return None
|
||||||
|
|
||||||
|
def parse_time_to_seconds(time_str):
|
||||||
|
h, m, s = time_str.split(':')
|
||||||
|
return int(h) * 3600 + int(m) * 60 + float(s)
|
||||||
|
|
||||||
|
def get_streams_map(filepath):
|
||||||
|
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", filepath]
|
||||||
|
try:
|
||||||
|
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
data = json.loads(res.stdout)
|
||||||
|
except: return ["-map", "0"]
|
||||||
|
map_args = ["-map", "0:v"]
|
||||||
|
audio_found = False
|
||||||
|
for s in data.get('streams', []):
|
||||||
|
if s['codec_type'] == 'audio':
|
||||||
|
lang = s.get('tags', {}).get('language', 'und').lower()
|
||||||
|
if lang in ['por', 'pt', 'eng', 'en', 'jpn', 'ja', 'und']:
|
||||||
|
map_args.extend(["-map", f"0:{s['index']}"])
|
||||||
|
audio_found = True
|
||||||
|
if not audio_found: map_args.extend(["-map", "0:a"])
|
||||||
|
for s in data.get('streams', []):
|
||||||
|
if s['codec_type'] == 'subtitle':
|
||||||
|
lang = s.get('tags', {}).get('language', 'und').lower()
|
||||||
|
if lang in ['por', 'pt', 'pob', 'pt-br']:
|
||||||
|
map_args.extend(["-map", f"0:{s['index']}"])
|
||||||
|
return map_args
|
||||||
|
|
||||||
|
# --- RENDERIZAÇÃO ---
|
||||||
|
def render():
|
||||||
|
st.header("⚙️ Encoder (Persistente)")
|
||||||
|
st.info("O processo continua rodando mesmo se você fechar a página.")
|
||||||
|
|
||||||
|
# 1. Lê estado do disco
|
||||||
|
status = load_status()
|
||||||
|
|
||||||
|
# Se não existe ou não está rodando
|
||||||
|
if not status or not status.get("is_running"):
|
||||||
|
all_folders = get_all_subfolders(ROOT_DIR)
|
||||||
|
idx = 0
|
||||||
|
if 'enc_last_folder' in st.session_state and st.session_state.enc_last_folder in all_folders:
|
||||||
|
idx = all_folders.index(st.session_state.enc_last_folder)
|
||||||
|
|
||||||
|
input_folder = st.selectbox("Selecione a pasta:", options=all_folders, index=idx, format_func=lambda x: x.replace(ROOT_DIR, "") if x != ROOT_DIR else "Raiz")
|
||||||
|
st.session_state.enc_last_folder = input_folder
|
||||||
|
|
||||||
|
delete_orig = st.checkbox("🗑️ Excluir originais após sucesso?", value=False)
|
||||||
|
|
||||||
|
if st.button("🚀 Iniciar Processo", type="primary"):
|
||||||
|
if not os.path.exists(input_folder):
|
||||||
|
st.error("Pasta inválida")
|
||||||
|
else:
|
||||||
|
# Inicia Thread
|
||||||
|
t = BackgroundWorker(input_folder, delete_orig)
|
||||||
|
t.start()
|
||||||
|
time.sleep(1) # Dá tempo de criar o arquivo json
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
else:
|
||||||
|
# MODO MONITORAMENTO
|
||||||
|
st.success("🔄 Sistema Rodando...")
|
||||||
|
|
||||||
|
col1, col2 = st.columns([3, 1])
|
||||||
|
with col1:
|
||||||
|
st.write(f"📁 **Processando:** `{status.get('current_file')}`")
|
||||||
|
st.progress(status.get('progress', 0), text=f"Arquivo: {status.get('progress')}%")
|
||||||
|
st.progress(status.get('total_progress', 0), text=f"Total: {status.get('total_progress')}%")
|
||||||
|
st.caption(status.get('log'))
|
||||||
|
|
||||||
|
with col2:
|
||||||
|
if st.button("🛑 Parar", type="secondary"):
|
||||||
|
status["stop_requested"] = True
|
||||||
|
save_status(status)
|
||||||
|
st.warning("Parando...")
|
||||||
|
|
||||||
|
if st.button("🔄 Refresh"):
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
# Atualiza a tela a cada 5s
|
||||||
|
time.sleep(5)
|
||||||
|
st.rerun()
|
||||||
223
app/modules/file_manager.py
Executable file
223
app/modules/file_manager.py
Executable file
@@ -0,0 +1,223 @@
|
|||||||
|
import streamlit as st
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import pandas as pd
|
||||||
|
import time
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
# Define a raiz absoluta
|
||||||
|
ROOT_DIR = "/downloads"
|
||||||
|
|
||||||
|
def get_human_size(size):
|
||||||
|
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||||
|
if size < 1024:
|
||||||
|
return f"{size:.2f} {unit}"
|
||||||
|
size /= 1024
|
||||||
|
return f"{size:.2f} PB"
|
||||||
|
|
||||||
|
def get_files_info(path):
|
||||||
|
files_list = []
|
||||||
|
try:
|
||||||
|
with os.scandir(path) as entries:
|
||||||
|
# Ordena: Pastas primeiro, depois arquivos (ambos alfabéticos)
|
||||||
|
sorted_entries = sorted(entries, key=lambda e: (not e.is_dir(), e.name.lower()))
|
||||||
|
|
||||||
|
for entry in sorted_entries:
|
||||||
|
stat = entry.stat()
|
||||||
|
dt_mod = datetime.datetime.fromtimestamp(stat.st_mtime).strftime('%d/%m/%Y %H:%M')
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"Selecionar": False,
|
||||||
|
"Tipo": "📁" if entry.is_dir() else "📄",
|
||||||
|
"Nome": entry.name,
|
||||||
|
"Tamanho": "-" if entry.is_dir() else get_human_size(stat.st_size),
|
||||||
|
"Modificado": dt_mod,
|
||||||
|
"is_dir": entry.is_dir() # Coluna oculta para lógica
|
||||||
|
}
|
||||||
|
files_list.append(info)
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Erro ao ler pasta: {e}")
|
||||||
|
return files_list
|
||||||
|
|
||||||
|
def get_all_subfolders(root_path):
|
||||||
|
folder_list = [root_path]
|
||||||
|
try:
|
||||||
|
for root, dirs, files in os.walk(root_path):
|
||||||
|
for d in dirs:
|
||||||
|
folder_list.append(os.path.join(root, d))
|
||||||
|
except: pass
|
||||||
|
return sorted(folder_list)
|
||||||
|
|
||||||
|
def render():
|
||||||
|
st.header("📂 Explorador de Arquivos")
|
||||||
|
|
||||||
|
# --- GESTÃO DE ESTADO (PATH) ---
|
||||||
|
if 'fm_path' not in st.session_state:
|
||||||
|
st.session_state.fm_path = ROOT_DIR
|
||||||
|
|
||||||
|
# Segurança: não deixa subir além da raiz
|
||||||
|
if not st.session_state.fm_path.startswith(ROOT_DIR):
|
||||||
|
st.session_state.fm_path = ROOT_DIR
|
||||||
|
|
||||||
|
current_path = st.session_state.fm_path
|
||||||
|
|
||||||
|
# --- BARRA DE NAVEGAÇÃO SUPERIOR ---
|
||||||
|
col_nav1, col_nav2, col_nav3, col_nav4 = st.columns([1, 1, 1, 6])
|
||||||
|
|
||||||
|
with col_nav1:
|
||||||
|
if st.button("⬆️ Voltar", use_container_width=True, help="Subir um nível"):
|
||||||
|
parent = os.path.dirname(current_path)
|
||||||
|
st.session_state.fm_path = parent
|
||||||
|
st.rerun()
|
||||||
|
with col_nav2:
|
||||||
|
if st.button("🏠 Raiz", use_container_width=True):
|
||||||
|
st.session_state.fm_path = ROOT_DIR
|
||||||
|
st.rerun()
|
||||||
|
with col_nav3:
|
||||||
|
if st.button("🔄 Reload", use_container_width=True):
|
||||||
|
st.rerun()
|
||||||
|
with col_nav4:
|
||||||
|
# Mostra o caminho bonitinho
|
||||||
|
display_path = current_path.replace(ROOT_DIR, " / Raiz")
|
||||||
|
st.info(f"📂 **Local:** `{display_path}`")
|
||||||
|
|
||||||
|
# --- LISTAGEM DE ARQUIVOS ---
|
||||||
|
files_data = get_files_info(current_path)
|
||||||
|
|
||||||
|
if not files_data:
|
||||||
|
st.warning("Pasta vazia.")
|
||||||
|
df = pd.DataFrame(columns=["Selecionar", "Tipo", "Nome", "Tamanho", "Modificado"])
|
||||||
|
else:
|
||||||
|
df = pd.DataFrame(files_data)
|
||||||
|
|
||||||
|
# Editor de Dados (Tabela)
|
||||||
|
edited_df = st.data_editor(
|
||||||
|
df,
|
||||||
|
column_config={
|
||||||
|
"Selecionar": st.column_config.CheckboxColumn("Sel.", width="small"),
|
||||||
|
"Tipo": st.column_config.TextColumn("", width="small"),
|
||||||
|
"Nome": st.column_config.TextColumn("Nome do Arquivo/Pasta", width="large"),
|
||||||
|
"Tamanho": st.column_config.TextColumn("Tamanho", width="medium"),
|
||||||
|
"Modificado": st.column_config.TextColumn("Data", width="medium"),
|
||||||
|
},
|
||||||
|
disabled=["Tipo", "Nome", "Tamanho", "Modificado"],
|
||||||
|
hide_index=True,
|
||||||
|
use_container_width=True,
|
||||||
|
key=f"editor_{current_path}" # Chave única para resetar ao mudar de pasta
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filtra Selecionados
|
||||||
|
selected_rows = edited_df[edited_df["Selecionar"] == True]
|
||||||
|
|
||||||
|
# --- BOTÃO RÁPIDO: ABRIR PASTA ---
|
||||||
|
# Se selecionou apenas 1 item e é pasta, mostra botão gigante para entrar
|
||||||
|
if len(selected_rows) == 1:
|
||||||
|
row = selected_rows.iloc[0]
|
||||||
|
if row["is_dir"]:
|
||||||
|
st.success(f"Selecionado: 📁 {row['Nome']}")
|
||||||
|
if st.button(f"Abrir Pasta '{row['Nome']}'", type="primary", use_container_width=True):
|
||||||
|
st.session_state.fm_path = os.path.join(current_path, row["Nome"])
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# --- PAINEL DE AÇÕES (ABAS) ---
|
||||||
|
tab_mov, tab_ren, tab_new, tab_del = st.tabs([
|
||||||
|
"📦 Mover Itens", "✏️ Renomear", "➕ Criar Pasta", "🗑️ Excluir"
|
||||||
|
])
|
||||||
|
|
||||||
|
# 1. ABA MOVER
|
||||||
|
with tab_mov:
|
||||||
|
st.caption("Mover itens selecionados para outra pasta.")
|
||||||
|
if selected_rows.empty:
|
||||||
|
st.info("Selecione itens na lista acima para mover.")
|
||||||
|
else:
|
||||||
|
all_folders = get_all_subfolders(ROOT_DIR)
|
||||||
|
# Remove a pasta atual da lista de destinos
|
||||||
|
all_folders = [f for f in all_folders if f != current_path]
|
||||||
|
|
||||||
|
dest_folder = st.selectbox(
|
||||||
|
"Escolha o destino:",
|
||||||
|
all_folders,
|
||||||
|
format_func=lambda x: x.replace(ROOT_DIR, "Raiz")
|
||||||
|
)
|
||||||
|
|
||||||
|
if st.button("Mover Agora", type="primary"):
|
||||||
|
count = 0
|
||||||
|
for _, row in selected_rows.iterrows():
|
||||||
|
src = os.path.join(current_path, row["Nome"])
|
||||||
|
dst = os.path.join(dest_folder, row["Nome"])
|
||||||
|
try:
|
||||||
|
shutil.move(src, dst)
|
||||||
|
count += 1
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Erro ao mover {row['Nome']}: {e}")
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
st.toast(f"✅ {count} itens movidos com sucesso!")
|
||||||
|
time.sleep(1)
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
# 2. ABA RENOMEAR
|
||||||
|
with tab_ren:
|
||||||
|
st.caption("Renomear o item selecionado (Selecione apenas 1).")
|
||||||
|
if len(selected_rows) != 1:
|
||||||
|
st.warning("Por segurança, selecione apenas 1 item para renomear.")
|
||||||
|
else:
|
||||||
|
row = selected_rows.iloc[0]
|
||||||
|
old_name = row["Nome"]
|
||||||
|
new_name = st.text_input("Novo nome:", value=old_name)
|
||||||
|
|
||||||
|
if new_name != old_name and st.button("Salvar Novo Nome"):
|
||||||
|
old_path = os.path.join(current_path, old_name)
|
||||||
|
new_path = os.path.join(current_path, new_name)
|
||||||
|
try:
|
||||||
|
os.rename(old_path, new_path)
|
||||||
|
st.toast(f"✅ Renomeado para {new_name}")
|
||||||
|
time.sleep(1)
|
||||||
|
st.rerun()
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Erro: {e}")
|
||||||
|
|
||||||
|
# 3. ABA CRIAR PASTA
|
||||||
|
with tab_new:
|
||||||
|
st.caption(f"Criar uma nova pasta dentro de: {os.path.basename(current_path)}")
|
||||||
|
new_folder_name = st.text_input("Nome da Nova Pasta:")
|
||||||
|
if st.button("Criar Pasta"):
|
||||||
|
if new_folder_name:
|
||||||
|
path_to_create = os.path.join(current_path, new_folder_name)
|
||||||
|
try:
|
||||||
|
os.makedirs(path_to_create, exist_ok=False)
|
||||||
|
st.toast(f"✅ Pasta '{new_folder_name}' criada!")
|
||||||
|
time.sleep(1)
|
||||||
|
st.rerun()
|
||||||
|
except FileExistsError:
|
||||||
|
st.error("Essa pasta já existe.")
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Erro: {e}")
|
||||||
|
|
||||||
|
# 4. ABA EXCLUIR
|
||||||
|
with tab_del:
|
||||||
|
if selected_rows.empty:
|
||||||
|
st.info("Selecione itens para excluir.")
|
||||||
|
else:
|
||||||
|
st.error(f"⚠️ Você tem {len(selected_rows)} itens selecionados.")
|
||||||
|
st.write("Tem certeza que deseja excluir permanentemente?")
|
||||||
|
|
||||||
|
col_del1, col_del2 = st.columns([1, 4])
|
||||||
|
with col_del1:
|
||||||
|
if st.button("🔥 SIM, EXCLUIR", type="primary"):
|
||||||
|
count = 0
|
||||||
|
for _, row in selected_rows.iterrows():
|
||||||
|
full_path = os.path.join(current_path, row["Nome"])
|
||||||
|
try:
|
||||||
|
if row["is_dir"]: shutil.rmtree(full_path)
|
||||||
|
else: os.remove(full_path)
|
||||||
|
count += 1
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Erro ao deletar {row['Nome']}: {e}")
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
st.toast(f"🗑️ {count} itens apagados.")
|
||||||
|
time.sleep(1)
|
||||||
|
st.rerun()
|
||||||
159
app/modules/renamer.py
Executable file
159
app/modules/renamer.py
Executable file
@@ -0,0 +1,159 @@
|
|||||||
|
import streamlit as st
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import pandas as pd
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Define a raiz absoluta
|
||||||
|
ROOT_DIR = "/downloads"
|
||||||
|
|
||||||
|
def get_all_subfolders(root_path):
|
||||||
|
folder_list = [root_path]
|
||||||
|
try:
|
||||||
|
for root, dirs, files in os.walk(root_path):
|
||||||
|
for d in dirs:
|
||||||
|
folder_list.append(os.path.join(root, d))
|
||||||
|
except: pass
|
||||||
|
return sorted(folder_list)
|
||||||
|
|
||||||
|
def extract_season_episode(filename):
|
||||||
|
"""
|
||||||
|
Testa uma lista de padrões regex para extrair Temporada e Episódio.
|
||||||
|
Retorna (season, episode) ou (None, None).
|
||||||
|
"""
|
||||||
|
# LISTA DE PADRÕES (Do mais específico para o mais genérico)
|
||||||
|
patterns = [
|
||||||
|
# 1. Padrão Scene/Universal: S01E01, S01.E01, S01_E01, S01 - E01
|
||||||
|
r'(?i)S(\d{1,4})[\s._-]*E(\d{1,4})',
|
||||||
|
|
||||||
|
# 2. Padrão Anime/P2P (Seu caso): S01EP01, S01.EP01
|
||||||
|
r'(?i)S(\d{1,4})[\s._-]*EP(\d{1,4})',
|
||||||
|
|
||||||
|
# 3. Padrão Antigo: 1x01, 01x01
|
||||||
|
r'(?i)(\d{1,4})x(\d{1,4})',
|
||||||
|
|
||||||
|
# 4. Padrão Extenso: Season 1 Episode 1
|
||||||
|
r'(?i)Season[\s._-]*(\d{1,4})[\s._-]*Episode[\s._-]*(\d{1,4})',
|
||||||
|
|
||||||
|
# 5. Padrão Hífen (Anime): "Nome S01 - 05.mkv"
|
||||||
|
r'(?i)S(\d{1,4})[\s._-]*-\s*(\d{1,4})',
|
||||||
|
|
||||||
|
# 6. Padrão Colchetes: [1x01]
|
||||||
|
r'(?i)\[(\d{1,4})x(\d{1,4})\]',
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, filename)
|
||||||
|
if match:
|
||||||
|
# Retorna Temporada, Episódio
|
||||||
|
return match.group(1), match.group(2)
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def render():
|
||||||
|
st.header("🏷️ Renomeador de Séries")
|
||||||
|
st.info("Suporta: S01E01, S01EP01, 1x01, S01 - 01, Season 1 Episode 1...")
|
||||||
|
|
||||||
|
# --- SELEÇÃO DE PASTA ---
|
||||||
|
all_folders = get_all_subfolders(ROOT_DIR)
|
||||||
|
|
||||||
|
idx = 0
|
||||||
|
if 'rn_last_folder' in st.session_state and st.session_state.rn_last_folder in all_folders:
|
||||||
|
idx = all_folders.index(st.session_state.rn_last_folder)
|
||||||
|
|
||||||
|
root_path = st.selectbox(
|
||||||
|
"Selecione a pasta para analisar:",
|
||||||
|
options=all_folders,
|
||||||
|
index=idx,
|
||||||
|
format_func=lambda x: x.replace(ROOT_DIR, "") if x != ROOT_DIR else "Raiz (/downloads)"
|
||||||
|
)
|
||||||
|
|
||||||
|
st.session_state.rn_last_folder = root_path
|
||||||
|
|
||||||
|
if st.button("🔍 Analisar Arquivos", type="primary"):
|
||||||
|
if not os.path.exists(root_path):
|
||||||
|
st.error("Pasta não encontrada.")
|
||||||
|
return
|
||||||
|
|
||||||
|
preview_data = []
|
||||||
|
ignored_files = []
|
||||||
|
|
||||||
|
# Varre a pasta
|
||||||
|
for root, dirs, files in os.walk(root_path):
|
||||||
|
# Ignora pastas do sistema
|
||||||
|
if "finalizados" in root or "temp" in root: continue
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
if file.lower().endswith(('.mkv', '.mp4', '.avi')):
|
||||||
|
|
||||||
|
# Chama a função inteligente de detecção
|
||||||
|
season, episode = extract_season_episode(file)
|
||||||
|
|
||||||
|
if season and episode:
|
||||||
|
try:
|
||||||
|
s_fmt = f"{int(season):02d}"
|
||||||
|
e_fmt = f"{int(episode):02d}"
|
||||||
|
ext = os.path.splitext(file)[1]
|
||||||
|
|
||||||
|
season_folder = f"Temporada {s_fmt}"
|
||||||
|
new_name = f"Episódio {e_fmt}{ext}"
|
||||||
|
|
||||||
|
original_full = os.path.join(root, file)
|
||||||
|
dest_full = os.path.join(root_path, season_folder, new_name)
|
||||||
|
|
||||||
|
preview_data.append({
|
||||||
|
"Arquivo Original": file,
|
||||||
|
"Nova Estrutura": f"{season_folder}/{new_name}",
|
||||||
|
"src": original_full,
|
||||||
|
"dst": dest_full
|
||||||
|
})
|
||||||
|
except:
|
||||||
|
ignored_files.append(file)
|
||||||
|
else:
|
||||||
|
ignored_files.append(file)
|
||||||
|
|
||||||
|
st.session_state['renamer_preview'] = preview_data
|
||||||
|
st.session_state['renamer_ignored'] = ignored_files
|
||||||
|
|
||||||
|
# --- RESULTADOS ---
|
||||||
|
if 'renamer_preview' in st.session_state and st.session_state['renamer_preview']:
|
||||||
|
st.divider()
|
||||||
|
st.subheader(f"✅ Identificados ({len(st.session_state['renamer_preview'])})")
|
||||||
|
|
||||||
|
df = pd.DataFrame(st.session_state['renamer_preview'])
|
||||||
|
st.dataframe(
|
||||||
|
df[["Arquivo Original", "Nova Estrutura"]],
|
||||||
|
use_container_width=True,
|
||||||
|
hide_index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
col1, col2 = st.columns([1, 4])
|
||||||
|
with col1:
|
||||||
|
if st.button("🚀 Confirmar", type="primary", use_container_width=True):
|
||||||
|
count = 0
|
||||||
|
for item in st.session_state['renamer_preview']:
|
||||||
|
dst_folder = os.path.dirname(item["dst"])
|
||||||
|
try:
|
||||||
|
if not os.path.exists(dst_folder):
|
||||||
|
os.makedirs(dst_folder, exist_ok=True)
|
||||||
|
|
||||||
|
if not os.path.exists(item["dst"]):
|
||||||
|
shutil.move(item["src"], item["dst"])
|
||||||
|
count += 1
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Erro ao mover {item['src']}: {e}")
|
||||||
|
|
||||||
|
st.success(f"{count} arquivos organizados com sucesso!")
|
||||||
|
time.sleep(2)
|
||||||
|
del st.session_state['renamer_preview']
|
||||||
|
del st.session_state['renamer_ignored']
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
# Exibe ignorados se houver, para debug do usuário
|
||||||
|
if 'renamer_ignored' in st.session_state and st.session_state['renamer_ignored']:
|
||||||
|
with st.expander("⚠️ Arquivos ignorados (Padrão desconhecido)", expanded=False):
|
||||||
|
st.write(st.session_state['renamer_ignored'])
|
||||||
|
|
||||||
|
elif 'renamer_preview' in st.session_state and not st.session_state['renamer_preview']:
|
||||||
|
st.warning("Nenhum arquivo de vídeo encontrado.")
|
||||||
22
docker-compose.yml
Executable file
22
docker-compose.yml
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
pymediamanager:
|
||||||
|
build: .
|
||||||
|
container_name: pymediamanager
|
||||||
|
privileged: true
|
||||||
|
restart: unless-stopped
|
||||||
|
devices:
|
||||||
|
- /dev/dri:/dev/dri
|
||||||
|
group_add:
|
||||||
|
- "993" # Grupo render/video
|
||||||
|
environment:
|
||||||
|
- TZ=America/Sao_Paulo
|
||||||
|
- LIBVA_DRIVER_NAME=i965 # Força driver Haswell
|
||||||
|
volumes:
|
||||||
|
# Mapeamento CORRIGIDO: pasta local 'app' vira '/app' no container
|
||||||
|
- /home/creidsu/pymediamanager/app:/app
|
||||||
|
- /home/creidsu/pymediamanager/data:/app/data
|
||||||
|
# Suas pastas de mídia
|
||||||
|
- /home/creidsu/downloads:/downloads
|
||||||
|
ports:
|
||||||
|
- 8501:8501
|
||||||
6
requirements.txt
Executable file
6
requirements.txt
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
streamlit
|
||||||
|
pandas
|
||||||
|
watchdog
|
||||||
|
guessit
|
||||||
|
requests
|
||||||
|
ffmpeg-python
|
||||||
Reference in New Issue
Block a user