Skip to content

Commit dce6e28

Browse files
committed
feat(library): add file library with audio-transcription correlation
- Create new library router with file management and deletion endpoints - Add library template with table view showing correlated files - Implement smart file correlation between uploads and transcriptions - Centralize template configuration in core.templates module - Add comprehensive tests for library functionality - Update navigation to include library link - Improve README documentation with new feature description
1 parent aa7cbcd commit dce6e28

File tree

12 files changed

+370
-36
lines changed

12 files changed

+370
-36
lines changed

.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ DEBUG=true
1414
FFMPEG_AUTO_SETUP=true # If true, attempts to auto-install FFmpeg via imageio-ffmpeg on startup
1515

1616
# Defines the device to use for transcription: auto (prefer GPU), cuda (force GPU), cpu (force CPU)
17-
TRANSCRIPTION_DEVICE=auto
17+
TRANSCRIPTION_DEVICE=cuda
1818

1919
# Whisper Configuration
2020
# WHISPER_MODEL=base # Options: tiny, base, small, medium, large-v2, large-v3

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ Built with **FastAPI**, **WebSockets**, **Alpine.js**, and **Tailwind CSS** for
2222

2323
* 🎥 **YouTube & Local Support:** Transcribe directly from YouTube URLs or upload `.mp3`, `.mp4`, `.wav`, and more.
2424
* 🚀 **Real-Time Progress:** Watch the transcription status live via **WebSockets** — no more guessing when it finishes.
25+
* 📚 **Smart Library:** Manage your media and transcriptions in one place. Auto-correlates audio files with their transcripts and allows one-click cleanup of paired files.
26+
* 🛡️ **Filename Sanitization:** Automatic handling of special characters, accents, and spaces in filenames for robust cross-platform compatibility.
2527
* 🤖 **Dual AI Engine:**
2628
* **openai-whisper**: High accuracy (default when FFmpeg is available).
2729
* **faster-whisper**: Blazing fast inference with seamless fallback.
@@ -30,7 +32,7 @@ Built with **FastAPI**, **WebSockets**, **Alpine.js**, and **Tailwind CSS** for
3032
* **Auto-FFmpeg:** Automatically detects or installs FFmpeg locally (Windows/Linux/Mac).
3133
* **yt-dlp Python API:** Robust media downloading without external binary dependencies.
3234
* 🐳 **Production Ready:** Optimized **Docker** image (multi-stage build, non-root user, secure).
33-
* 🌓 **Modern UI:** Dark/light mode, responsive design, and history management.
35+
* 🌓 **Modern UI:** Dark/light mode, responsive design, history management, and file library.
3436

3537
---
3638

@@ -44,6 +46,7 @@ Built with **FastAPI**, **WebSockets**, **Alpine.js**, and **Tailwind CSS** for
4446
* `imageio-ffmpeg` (Auto-setup)
4547
* `torch` (PyTorch with CUDA 12.4 support)
4648
* **DevOps:** Docker (Multi-stage), GitHub Actions (CI), Pytest
49+
* **Storage:** Local filesystem with smart correlation (Library)
4750

4851
---
4952

@@ -59,12 +62,13 @@ app/
5962
│ ├─ home.py # UI: Homepage
6063
│ ├─ upload.py # API: Handle file/YouTube uploads
6164
│ ├─ websocket.py # API: Real-time progress updates
65+
│ ├─ library.py # API: Manage audio/transcription files
6266
│ └─ history.py # UI: Transcription history
6367
├─ services/
6468
│ ├─ progress.py # Task state management
6569
│ ├─ youtube.py # yt-dlp integration
6670
│ ├─ transcriber.py # Whisper engine & FFmpeg logic
67-
│ └─ file_manager.py # File I/O operations
71+
│ └─ file_manager.py # File I/O operations & Sanitization
6872
├─ scripts/
6973
│ └─ setup_ffmpeg.py # Auto-installer for FFmpeg
7074
├─ templates/ # Jinja2 + Alpine.js templates

app/core/templates.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from fastapi.templating import Jinja2Templates
2+
from datetime import datetime
3+
from .config import settings
4+
from .theme import default_theme
5+
6+
# Create a single Jinja2Templates instance
7+
templates = Jinja2Templates(directory=str(settings.templates_dir))
8+
9+
# Register Filters
10+
def timestamp_to_date(timestamp: float) -> str:
11+
try:
12+
return datetime.fromtimestamp(timestamp).strftime('%d/%m/%Y %H:%M')
13+
except Exception:
14+
return ""
15+
16+
templates.env.filters["timestamp_to_date"] = timestamp_to_date
17+
18+
# Register Globals
19+
templates.env.globals["theme"] = default_theme()

app/main.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
from fastapi import FastAPI, Request
1+
from fastapi import FastAPI
22
from fastapi.staticfiles import StaticFiles
3-
from fastapi.templating import Jinja2Templates
43
from .core.config import settings
5-
from .core.theme import default_theme
64
from .scripts.setup_ffmpeg import setup_ffmpeg
5+
from .core.templates import templates # Importa templates configurados
76

87
# Executar setup do FFmpeg se configurado
98
if settings.ffmpeg_auto_setup:
@@ -14,9 +13,6 @@
1413
# Mount static files
1514
app.mount("/static", StaticFiles(directory=settings.static_dir), name="static")
1615

17-
# Templates
18-
templates = Jinja2Templates(directory=str(settings.templates_dir))
19-
2016
from fastapi.responses import FileResponse # noqa: E402
2117

2218

@@ -26,15 +22,13 @@ async def download(filename: str):
2622
return FileResponse(path=str(file_path), filename=filename, media_type="text/plain")
2723

2824
# Include routers (to be implemented)
29-
from .routers import home, upload, history, websocket # noqa: E402
25+
from .routers import home, upload, history, websocket, library # noqa: E402
3026

3127
app.include_router(home.router)
3228
app.include_router(upload.router)
3329
app.include_router(history.router)
3430
app.include_router(websocket.router)
35-
36-
# Add template globals
37-
templates.env.globals["theme"] = default_theme()
31+
app.include_router(library.router)
3832

3933

4034
@app.get("/health")

app/routers/history.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,10 @@
11
from fastapi import APIRouter, Request
22
from fastapi.responses import HTMLResponse
3-
from fastapi.templating import Jinja2Templates
4-
from ..core.config import settings
53
from ..services.file_manager import list_transcriptions
6-
from ..core.theme import default_theme
4+
from ..core.templates import templates
75

86
router = APIRouter(prefix="/history")
97

10-
templates = Jinja2Templates(directory=str(settings.templates_dir))
11-
# Registrar tema padrão como global para os templates
12-
templates.env.globals["theme"] = default_theme()
13-
14-
158
@router.get("/", response_class=HTMLResponse)
169
async def history(request: Request):
1710
files = list_transcriptions()

app/routers/home.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
11
from fastapi import APIRouter, Request
22
from fastapi.responses import HTMLResponse
3-
from fastapi.templating import Jinja2Templates
4-
from ..core.config import settings
5-
from ..core.theme import default_theme
3+
from ..core.templates import templates
64

75
router = APIRouter()
86

9-
templates = Jinja2Templates(directory=str(settings.templates_dir))
10-
# Registrar tema padrão como global para os templates
11-
templates.env.globals["theme"] = default_theme()
12-
13-
147
@router.get("/", response_class=HTMLResponse)
158
async def index(request: Request):
169
return templates.TemplateResponse(request=request, name="index.html")

app/routers/library.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from __future__ import annotations
2+
from pathlib import Path
3+
import logging
4+
from typing import Optional
5+
from fastapi import APIRouter, Request, HTTPException
6+
from fastapi.responses import HTMLResponse, RedirectResponse
7+
from ..core.config import settings
8+
from ..core.templates import templates
9+
import logging
10+
from pathlib import Path
11+
from typing import Optional
12+
13+
router = APIRouter(prefix="/library")
14+
15+
logger = logging.getLogger(__name__)
16+
17+
def get_library_items():
18+
transcriptions_dir = settings.storage_transcriptions
19+
uploads_dir = settings.storage_uploads
20+
21+
# Ensure directories exist
22+
if not transcriptions_dir.exists():
23+
transcriptions_dir.mkdir(parents=True)
24+
if not uploads_dir.exists():
25+
uploads_dir.mkdir(parents=True)
26+
27+
transcription_files = sorted(
28+
[p for p in transcriptions_dir.glob("*.txt") if p.is_file()],
29+
key=lambda p: p.stat().st_mtime,
30+
reverse=True
31+
)
32+
33+
audio_files = [p for p in uploads_dir.glob("*") if p.is_file() and p.name != ".keep"]
34+
35+
# Map stem -> audio file(s)
36+
# We need to handle:
37+
# 1. Exact stem match: video.mp4 -> video.txt
38+
# 2. UUID prefix: uuid_video.mp4 -> video.txt
39+
40+
items = []
41+
processed_audio = set()
42+
43+
for t_file in transcription_files:
44+
stem = t_file.stem
45+
related_audio = None
46+
47+
# Strategy 1: Exact stem match
48+
for a_file in audio_files:
49+
if a_file in processed_audio:
50+
continue
51+
if a_file.stem == stem:
52+
related_audio = a_file
53+
processed_audio.add(a_file)
54+
break
55+
56+
# Strategy 2: Check for uuid_stem pattern if not found
57+
if not related_audio:
58+
for a_file in audio_files:
59+
if a_file in processed_audio:
60+
continue
61+
# Check if audio file ends with _{stem}.ext or just contains stem
62+
# Simple check: uuid_filename.ext -> filename is at the end of stem?
63+
# Actually, our upload logic is: {task_id}_{filename}
64+
# And transcription logic is: get_unique_stem(filename) -> stem
65+
# So if filename was "video.mp4", audio is "uuid_video.mp4", transcription is "video.txt"
66+
# audio.stem is "uuid_video"
67+
# t.stem is "video"
68+
if a_file.stem.endswith(f"_{stem}"):
69+
related_audio = a_file
70+
processed_audio.add(a_file)
71+
break
72+
73+
items.append({
74+
"transcription": t_file.name,
75+
"transcription_path": str(t_file),
76+
"audio": related_audio.name if related_audio else None,
77+
"audio_path": str(related_audio) if related_audio else None,
78+
"date": t_file.stat().st_mtime,
79+
"size": t_file.stat().st_size
80+
})
81+
82+
# Add orphaned audio files
83+
for a_file in audio_files:
84+
if a_file not in processed_audio:
85+
items.append({
86+
"transcription": None,
87+
"transcription_path": None,
88+
"audio": a_file.name,
89+
"audio_path": str(a_file),
90+
"date": a_file.stat().st_mtime,
91+
"size": a_file.stat().st_size
92+
})
93+
94+
# Sort by date descending
95+
items.sort(key=lambda x: x["date"], reverse=True)
96+
return items
97+
98+
@router.get("/", response_class=HTMLResponse)
99+
async def library_view(request: Request):
100+
items = get_library_items()
101+
return templates.TemplateResponse(request=request, name="library.html", context={"items": items})
102+
103+
@router.post("/delete")
104+
async def delete_item(request: Request):
105+
form = await request.form()
106+
transcription_path = form.get("transcription_path")
107+
audio_path = form.get("audio_path")
108+
109+
def safe_delete(path_str: Optional[str], allowed_dir: Path):
110+
if not path_str:
111+
return
112+
try:
113+
p = Path(path_str).resolve()
114+
# Security check: ensure path is within allowed directory
115+
if not p.is_relative_to(allowed_dir.resolve()):
116+
logger.warning(f"Tentativa de deletar arquivo fora do diretório permitido: {p}")
117+
return
118+
if p.exists() and p.is_file():
119+
p.unlink()
120+
logger.info(f"Arquivo deletado: {p}")
121+
except Exception as e:
122+
logger.error(f"Erro ao deletar arquivo {path_str}: {e}")
123+
124+
safe_delete(transcription_path, settings.storage_transcriptions)
125+
safe_delete(audio_path, settings.storage_uploads)
126+
127+
return RedirectResponse(url="/library", status_code=303)

app/routers/upload.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,19 @@
11
from __future__ import annotations
22
from pathlib import Path
3-
import os
43
import logging
54
import uuid
65
import asyncio
76
from fastapi import APIRouter, File, Form, HTTPException, UploadFile, Request, BackgroundTasks
8-
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
9-
from fastapi.templating import Jinja2Templates
7+
from fastapi.responses import HTMLResponse
108
from ..core.config import settings
119
from ..services.youtube import download_from_youtube
1210
from ..services.transcriber import transcribe_file
1311
from ..services.file_manager import save_upload, save_transcription, get_unique_stem, sanitize_filename
1412
from ..services.progress import progress_manager
15-
from ..core.theme import default_theme
13+
from ..core.templates import templates
1614

1715
router = APIRouter(prefix="/transcribe")
1816

19-
templates = Jinja2Templates(directory=str(settings.templates_dir))
20-
templates.env.globals["theme"] = default_theme()
21-
2217
logger = logging.getLogger(__name__)
2318

2419
async def process_transcription(task_id: str, media_path: Path, original_filename: str):

app/templates/base.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<div class="max-w-5xl mx-auto px-4 py-4 flex items-center justify-between">
2222
<a href="/" class="text-xl font-semibold text-primary">Transcriber</a>
2323
<div class="flex items-center gap-4">
24+
<a href="/library" class="text-sm hover:underline">Biblioteca</a>
2425
<a href="/history" class="text-sm hover:underline">Histórico</a>
2526
<button class="px-3 py-1 rounded border border-gray-300 dark:border-gray-700 text-sm" @click="dark = !dark">
2627
<span x-show="!dark">Dark</span>

app/templates/library.html

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
{% extends 'base.html' %}
2+
3+
{% block content %}
4+
<div class="max-w-6xl mx-auto px-4 py-8">
5+
<div class="flex justify-between items-center mb-6">
6+
<h2 class="text-2xl font-bold text-gray-800 dark:text-gray-100">Biblioteca de Arquivos</h2>
7+
<span class="text-sm text-gray-500 dark:text-gray-400">Gerencie suas transcrições e áudios</span>
8+
</div>
9+
10+
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
11+
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
12+
<thead class="bg-gray-50 dark:bg-gray-700">
13+
<tr>
14+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
15+
Transcrição
16+
</th>
17+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
18+
Áudio Associado
19+
</th>
20+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
21+
Data
22+
</th>
23+
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
24+
Ações
25+
</th>
26+
</tr>
27+
</thead>
28+
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
29+
{% for item in items %}
30+
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
31+
<td class="px-6 py-4 whitespace-nowrap">
32+
{% if item.transcription %}
33+
<div class="flex items-center">
34+
<div class="flex-shrink-0 h-8 w-8 flex items-center justify-center rounded bg-blue-100 text-blue-500">
35+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
36+
</div>
37+
<div class="ml-4">
38+
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ item.transcription }}</div>
39+
<div class="text-xs text-gray-500 dark:text-gray-400">{{ (item.size / 1024)|round(1) }} KB</div>
40+
</div>
41+
</div>
42+
{% else %}
43+
<span class="text-xs text-gray-400 italic">Sem transcrição</span>
44+
{% endif %}
45+
</td>
46+
<td class="px-6 py-4 whitespace-nowrap">
47+
{% if item.audio %}
48+
<div class="flex items-center">
49+
<div class="flex-shrink-0 h-8 w-8 flex items-center justify-center rounded bg-purple-100 text-purple-500">
50+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path></svg>
51+
</div>
52+
<div class="ml-4">
53+
<div class="text-sm text-gray-900 dark:text-gray-100">{{ item.audio }}</div>
54+
</div>
55+
</div>
56+
{% else %}
57+
<span class="text-xs text-gray-400 italic">Áudio removido</span>
58+
{% endif %}
59+
</td>
60+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
61+
{{ item.date | timestamp_to_date }}
62+
</td>
63+
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
64+
<div class="flex justify-end space-x-3">
65+
{% if item.transcription %}
66+
<a href="/download/{{ item.transcription }}" class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300" title="Baixar Transcrição">
67+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
68+
</a>
69+
{% endif %}
70+
71+
<form action="/library/delete" method="POST" onsubmit="return confirm('Tem certeza que deseja excluir este item? Esta ação removerá a transcrição e o áudio associado.');" class="inline">
72+
<input type="hidden" name="transcription_path" value="{{ item.transcription_path or '' }}">
73+
<input type="hidden" name="audio_path" value="{{ item.audio_path or '' }}">
74+
<button type="submit" class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300" title="Excluir">
75+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
76+
</button>
77+
</form>
78+
</div>
79+
</td>
80+
</tr>
81+
{% endfor %}
82+
</tbody>
83+
</table>
84+
</div>
85+
</div>
86+
{% endblock %}

0 commit comments

Comments
 (0)