From 0d3e7397a7cdd3c6db2c2a25fae46f42dd3f0d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Henrique?= Date: Thu, 10 Sep 2026 13:49:58 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20criado=20reposit=C3=B3rio=20jhonny-edit?= =?UTF-8?q?or=20no=20Gitea?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit criado repositório jhonny-editor no Gitea adicionado script admin/deploy.command com commit automático atualizado admin/DEV-NOTES.md com template limpo Resumo: - 4 arquivos alterados - 2 novos - 2 modificados - 0 removidos 2 files changed, 48 insertions(+) Arquivos: - .jhonny/analises.db - code/engine/persistencia/esquema.py - code/engine/analisar_trilhas.py - code/engine/persistencia/repositorio_de_trilhas.py --- .jhonny/analises.db | Bin 1118208 -> 1155072 bytes code/engine/analisar_trilhas.py | 70 +++++++++++ code/engine/persistencia/esquema.py | 48 ++++++++ .../persistencia/repositorio_de_trilhas.py | 114 ++++++++++++++++++ 4 files changed, 232 insertions(+) create mode 100644 code/engine/analisar_trilhas.py create mode 100644 code/engine/persistencia/repositorio_de_trilhas.py diff --git a/.jhonny/analises.db b/.jhonny/analises.db index 3ab86ab4e7d2c7b67da02cfcda2928acd8c14656..4217284d5b4dc95a4b2813b5ae2239e2a63407d2 100644 GIT binary patch delta 2545 zcmc&#Z)_7~7=Pclf9`r+`@VH69ZcXr!-mqW9SgF_g#9U`vTTAKE@R1Y+nso%>y=&$ zh#_71Qz8U2(wLJkW`u;`r%e~BAZCIlL^Pm&F~$s06HScD2O}R865qS7V{5_qjZ4~l z@Ap2>bHC^JJnwTOW1S-(Zyc@hNC3b^VD@shoLse`nH|g2&lY5iTVOb5ucvKk@hg4Y;Bvpt< z#h8k!>=r*XmlTu*S)vvy!f{aTcz}7XbNp!=htB}-q2zczISxn1EN)nlXI?;iZE&$Z zvIQ>8ztxMP{Vc*>fWz2J-|xUpjfe#xNa?nAd@>nVL|&5lKxj?d+O>_%je*8B{y?)o z7>ER)X=!S1X=caIQ4R0SGG@CKL7`efu;@x?Xb>p*9MAH;s0?ylfFl zQK;`GI_~l^6ml4jdnt6=fNXXOv6U>7*-4@7_h^5P$4roy_yOM zgRLwJ(EnAkv40(8fg@x%-S@e_dPp7CwloFnX?6NJS~i650DKqUG5G(AIh%#jM;432 zktLQav1W-aOYB*~WC@!k6y&nRnI#oj;>r?tmf-V*z_FF=IY8hs;5zC2-MNc%Iaja~ zj?b7nRgfq$iyU0v#`uCkbhtJt4n}1;sq9Y4Nl_Dq zQ<^d)Dq1-Lt2?%a+9Dx7($?M;;>!*6wo4?)h%qrr-F19{@MPv;Hmv^DcG}MMeXv?7 z`ty-cUxe@Op}*d)M^1|^($dETxLgkmK)hK^Nb)cr?v8{ug|-%%c5y2YCDt>(h6Z%h zV`^BMt5ycU1v!yfz!oJo^|B`uO7s=clnWQu^VMSF33ZPwsrpR;^0})HS6YyV1x?OwNx}I(vdfqpf3}4X=-dvw4sC|>X!#dXZPXd>lmNkkB+8H zol26_N}plnygn=4HdV95MJ;KHwhS99bI2Af%@)gzIlMJ|Z+Cc0Z>W%KOdXbu@hm2G zr<6TFRjtfeeopM6#^SP4m`*B+WaWvo260O2ZE_`DGI%1 z97;_Jjs^p|Q%Q%$90F#{{M{+SZeRcbfZfLGo-eJT$w0u)=C`q)oR!wj;>+)NiBww7 zPQbu>qmZ0|BfRme;r;|xbPcd)Z9kxE&S9hAg$rGwU-z@9!oJaN0@gug7t+x%2)wI3HwmJQ(q9r=6Td&z>(V~zikjJiPpz;a(jX^>n(XveY zppqjtA8@}w;C*l!Eci!$A None: + """Emite uma linha de progresso consumível pelo painel CEP.""" + print("@@PROGRESS " + json.dumps({"evento": evento, **dados}, ensure_ascii=False), flush=True) + + +def executar(pasta: Path, banco: Path, diretorio_dos_modelos: Path | None) -> int: + """Analisa todos os arquivos musicais diretamente encontrados na pasta.""" + arquivos = tuple(sorted( + arquivo for arquivo in pasta.iterdir() + if arquivo.is_file() and arquivo.suffix.lower() in EXTENSOES_DE_AUDIO + )) + conexao = abrir_banco(banco) + try: + repositorio = RepositorioDeTrilhasSQLite(conexao) + trilhas = repositorio.registrar_pasta_e_arquivos(pasta, arquivos) + classificador = ClassificadorDeMusicaEssentia(diretorio_dos_modelos) if diretorio_dos_modelos else None + analisador = AnalisadorDeMusicaInstrumentalEssentia(classificador=classificador) + emitir("lote_iniciado", total=len(trilhas), pasta=str(pasta)) + for ordem, trilha in enumerate(trilhas, start=1): + repositorio.iniciar_analise(trilha.identificador) + emitir("trilha_iniciada", nome=trilha.nome_do_arquivo, indice=ordem, total=len(trilhas), percentual=(ordem - 1) * 100 / max(1, len(trilhas))) + try: + resultado = analisador.analisar(trilha.caminho) + repositorio.registrar_analise(trilha, resultado) + emitir("trilha_concluida", nome=trilha.nome_do_arquivo, indice=ordem, total=len(trilhas), percentual=ordem * 100 / max(1, len(trilhas))) + except (OSError, RuntimeError, ValueError) as erro: + repositorio.registrar_erro(trilha.identificador, str(erro)) + emitir("trilha_com_erro", nome=trilha.nome_do_arquivo, indice=ordem, total=len(trilhas), erro=str(erro), percentual=ordem * 100 / max(1, len(trilhas))) + emitir("lote_concluido", total=len(trilhas), percentual=100) + return 0 + finally: + conexao.close() + + +def principal() -> None: + """Lê argumentos e executa o lote de análise.""" + argumentos = argparse.ArgumentParser() + argumentos.add_argument("pasta", type=Path) + argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db")) + argumentos.add_argument("--modelos", type=Path, default=None) + opcoes = argumentos.parse_args() + try: + raise SystemExit(executar(opcoes.pasta, opcoes.banco, opcoes.modelos)) + except (OSError, ValueError) as erro: + emitir("lote_com_erro", erro=str(erro)) + raise SystemExit(1) from erro + + +if __name__ == "__main__": + principal() diff --git a/code/engine/persistencia/esquema.py b/code/engine/persistencia/esquema.py index b991353..4e8aeb8 100644 --- a/code/engine/persistencia/esquema.py +++ b/code/engine/persistencia/esquema.py @@ -61,6 +61,54 @@ CREATE TABLE IF NOT EXISTS arquivos ( UNIQUE (video_id, caminho) ); +-- Catálogo independente de músicas para uso como trilha de fundo. +CREATE TABLE IF NOT EXISTS pastas_de_trilhas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + caminho TEXT NOT NULL UNIQUE, + criada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + atualizada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +CREATE TABLE IF NOT EXISTS trilhas_musicais ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pasta_id INTEGER NOT NULL REFERENCES pastas_de_trilhas(id) ON DELETE CASCADE, + caminho TEXT NOT NULL UNIQUE, + nome_do_arquivo TEXT NOT NULL, + hash_do_conteudo TEXT, + tamanho_em_bytes INTEGER, + status TEXT NOT NULL DEFAULT 'aguardando', + erro TEXT, + criada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + atualizada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_trilhas_musicais_pasta ON trilhas_musicais(pasta_id); + +CREATE TABLE IF NOT EXISTS analises_musicais ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trilha_id INTEGER NOT NULL REFERENCES trilhas_musicais(id) ON DELETE CASCADE, + provedor TEXT NOT NULL, + modelo TEXT, + duracao_em_segundos REAL, + batidas_por_minuto REAL, + tonalidade TEXT, + modo TEXT, + intensidade REAL, + dancabilidade REAL, + resultado_bruto_json TEXT, + criada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_analises_musicais_trilha ON analises_musicais(trilha_id); + +CREATE TABLE IF NOT EXISTS etiquetas_musicais ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + analise_id INTEGER NOT NULL REFERENCES analises_musicais(id) ON DELETE CASCADE, + tipo TEXT NOT NULL, + etiqueta TEXT NOT NULL, + confianca REAL NOT NULL, + ordem INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_etiquetas_musicais_analise ON etiquetas_musicais(analise_id); + CREATE TABLE IF NOT EXISTS analises_versao ( video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE, etapa TEXT NOT NULL, diff --git a/code/engine/persistencia/repositorio_de_trilhas.py b/code/engine/persistencia/repositorio_de_trilhas.py new file mode 100644 index 0000000..7e98794 --- /dev/null +++ b/code/engine/persistencia/repositorio_de_trilhas.py @@ -0,0 +1,114 @@ +"""Persistência do catálogo independente de trilhas musicais.""" + +from __future__ import annotations + +import hashlib +import json +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +from ..integracoes.audio.modelos_de_analise_musical import ResultadoDaAnaliseMusical +from .conexao import abrir_banco + + +@dataclass(frozen=True) +class TrilhaMusical: + """Representa um arquivo de música catalogado, sem vínculo com vídeo.""" + + identificador: int + caminho: Path + nome_do_arquivo: str + status: str + erro: str | None = None + + +class RepositorioDeTrilhasSQLite: + """Salva pastas, trilhas e versões das análises musicais no SQLite.""" + + def __init__(self, banco: str | Path | sqlite3.Connection = ".jhonny/analises.db") -> None: + """Abre o banco informado e garante que o esquema esteja disponível.""" + self.conexao = banco if isinstance(banco, sqlite3.Connection) else abrir_banco(banco) + + def registrar_pasta_e_arquivos(self, pasta: str | Path, arquivos: Iterable[Path]) -> list[TrilhaMusical]: + """Registra a pasta e torna sua lista de arquivos pronta para análise.""" + caminho_da_pasta = Path(pasta).expanduser().resolve(strict=True) + if not caminho_da_pasta.is_dir(): + raise NotADirectoryError(f"A pasta de trilhas não existe: {caminho_da_pasta}") + arquivos_materializados = tuple(Path(arquivo).resolve() for arquivo in arquivos) + with self.conexao: + cursor = self.conexao.execute( + """INSERT INTO pastas_de_trilhas(caminho) VALUES (?) + ON CONFLICT(caminho) DO UPDATE SET atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now')""", + (str(caminho_da_pasta),), + ) + pasta_id = cursor.lastrowid or self.conexao.execute( + "SELECT id FROM pastas_de_trilhas WHERE caminho = ?", (str(caminho_da_pasta),) + ).fetchone()[0] + resultado: list[TrilhaMusical] = [] + for arquivo in arquivos_materializados: + linha = self.conexao.execute( + """INSERT INTO trilhas_musicais(pasta_id, caminho, nome_do_arquivo, tamanho_em_bytes) + VALUES (?, ?, ?, ?) + ON CONFLICT(caminho) DO UPDATE SET nome_do_arquivo = excluded.nome_do_arquivo, + tamanho_em_bytes = excluded.tamanho_em_bytes, atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now')""", + (pasta_id, str(arquivo), arquivo.name, arquivo.stat().st_size), + ) + identificador = linha.lastrowid or self.conexao.execute( + "SELECT id FROM trilhas_musicais WHERE caminho = ?", (str(arquivo),) + ).fetchone()[0] + registro = self.conexao.execute( + "SELECT id, caminho, nome_do_arquivo, status, erro FROM trilhas_musicais WHERE id = ?", + (identificador,), + ).fetchone() + resultado.append(TrilhaMusical(registro[0], Path(registro[1]), registro[2], registro[3], registro[4])) + return resultado + + def iniciar_analise(self, trilha_id: int) -> None: + """Marca uma trilha como em análise e remove erro anterior.""" + with self.conexao: + self.conexao.execute( + "UPDATE trilhas_musicais SET status = 'analisando', erro = NULL, atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", + (trilha_id,), + ) + + def registrar_analise(self, trilha: TrilhaMusical, resultado: ResultadoDaAnaliseMusical) -> None: + """Grava descritores e etiquetas produzidos pelo áudio da trilha.""" + bruto = json.dumps({"generos": resultado.generos, "humores": resultado.humores}, ensure_ascii=False) + with self.conexao: + self.conexao.execute( + "UPDATE trilhas_musicais SET hash_do_conteudo = ?, status = 'concluida', erro = NULL, atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", + (self._calcular_hash(trilha.caminho), trilha.identificador), + ) + cursor = self.conexao.execute( + """INSERT INTO analises_musicais + (trilha_id, provedor, modelo, duracao_em_segundos, batidas_por_minuto, tonalidade, modo, intensidade, dancabilidade, resultado_bruto_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (trilha.identificador, resultado.provedor, resultado.modelo, resultado.duracao_em_segundos, + resultado.batidas_por_minuto, resultado.tonalidade, resultado.modo, resultado.intensidade, + resultado.dancabilidade, bruto), + ) + analise_id = cursor.lastrowid + for tipo, etiquetas in (("genero", resultado.pontuacoes_de_genero), ("humor", resultado.pontuacoes_de_humor)): + self.conexao.executemany( + "INSERT INTO etiquetas_musicais(analise_id, tipo, etiqueta, confianca, ordem) VALUES (?, ?, ?, ?, ?)", + ((analise_id, tipo, item.etiqueta, item.confianca, ordem) for ordem, item in enumerate(etiquetas)), + ) + + def registrar_erro(self, trilha_id: int, erro: str) -> None: + """Marca uma trilha como erro sem interromper o lote inteiro.""" + with self.conexao: + self.conexao.execute( + "UPDATE trilhas_musicais SET status = 'erro', erro = ?, atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", + (erro[:2000], trilha_id), + ) + + @staticmethod + def _calcular_hash(caminho: Path) -> str: + """Calcula SHA-256 em blocos para não carregar o áudio inteiro na memória.""" + resumo = hashlib.sha256() + with caminho.open("rb") as arquivo: + for bloco in iter(lambda: arquivo.read(1024 * 1024), b""): + resumo.update(bloco) + return resumo.hexdigest()