This commit is contained in:
2026-06-15 01:06:05 +07:00
commit 6e42f05b55
15 changed files with 3410 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# Environment files
.env
.env.local
.env.*.local
# Uploads and temporary files
uploads/
chunks/
transcripts/
*.mp4
*.mp3
*.wav
# Python
__pycache__/
*.py[cod]
*$py.class
venv/
env/
+28
View File
@@ -0,0 +1,28 @@
# app.py
from flask import Flask
from config import Config
from routes.transcriptionRoute import transcription_bp
from routes.scraperZLibraryRoute import scraperZLibraryRoute
def create_app():
"""Application factory function"""
app = Flask(__name__, template_folder='views')
# Load configuration
app.config.from_object(Config)
# Remove MAX_CONTENT_LENGTH limit for chunked uploads
app.config['MAX_CONTENT_LENGTH'] = None
# Initialize folders
Config.init_app()
# Register blueprints
app.register_blueprint(transcription_bp)
app.register_blueprint(scraperZLibraryRoute, url_prefix='/scraperZLibrary')
return app
if __name__ == '__main__':
app = create_app()
app.run(debug=True, host='0.0.0.0', port=5000)
+32
View File
@@ -0,0 +1,32 @@
# config.py
import os
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
class Config:
# App settings
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
# Upload settings
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER') or 'uploads'
CHUNKS_FOLDER = os.environ.get('CHUNKS_FOLDER') or 'chunks'
TRANSCRIPTS_FOLDER = os.environ.get('TRANSCRIPTS_FOLDER') or 'transcripts'
ALLOWED_EXTENSIONS = {'mp4', 'avi', 'mov', 'mkv', 'mp3', 'wav', 'm4a'}
# No MAX_CONTENT_LENGTH limit for chunked uploads
MAX_CONTENT_LENGTH = None
# Transcription settings
CHUNK_LENGTH_MINUTES = int(os.environ.get('CHUNK_LENGTH_MINUTES', 3)) # Changed to 3 minutes
WHISPER_MODEL = os.environ.get('WHISPER_MODEL', 'medium')
# Debug mode
DEBUG = os.environ.get('FLASK_ENV') == 'development'
@staticmethod
def init_app():
os.makedirs(Config.UPLOAD_FOLDER, exist_ok=True)
os.makedirs(Config.CHUNKS_FOLDER, exist_ok=True)
os.makedirs(Config.TRANSCRIPTS_FOLDER, exist_ok=True)
+534
View File
@@ -0,0 +1,534 @@
# controllers/scraperZLibraryController.py
import json
import pathlib
import queue
import re
import tempfile
import threading
import time
import unicodedata
import uuid
from datetime import datetime
from typing import Dict, List, Optional
from urllib.parse import quote
from flask import Response, jsonify, render_template, request
from playwright.sync_api import sync_playwright
from models.scraperZLibraryModel import ZLibraryScraperModel
# ---------------------------------------------------------------------------
# Temp file store — filesystem-backed so ALL gunicorn workers can access it.
#
# WHY: Python dicts are per-process. With multiple gunicorn workers the SSE
# stream (worker A) stores a token in memory, but the follow-up GET request
# for that token may be routed to worker B whose dict is empty → 404.
#
# Using a shared temp directory on disk solves this without Redis or any
# external dependency. Each token is two files in _STORE_DIR:
# <token>.bin — raw file bytes
# <token>.meta — JSON: {filename, size, expires}
# ---------------------------------------------------------------------------
_STORE_DIR = pathlib.Path(tempfile.gettempdir()) / 'zlibrary_file_store'
_STORE_DIR.mkdir(exist_ok=True)
_TTL = 300 # seconds a token stays valid (5 min)
def _store_file(filename: str, content: bytes) -> str:
"""Write file bytes + metadata to disk; return a one-time token."""
token = uuid.uuid4().hex
meta = json.dumps({
'filename': filename,
'size': len(content),
'expires': time.time() + _TTL,
})
(_STORE_DIR / f'{token}.meta').write_text(meta, encoding='utf-8')
(_STORE_DIR / f'{token}.bin').write_bytes(content)
return token
def _pop_file(token: str) -> Optional[Dict]:
"""
Read and delete a stored file by token.
Returns None if the token does not exist or has expired.
Token is one-time-use: files are removed whether expired or not.
"""
meta_path = _STORE_DIR / f'{token}.meta'
bin_path = _STORE_DIR / f'{token}.bin'
if not meta_path.exists() or not bin_path.exists():
return None
try:
meta = json.loads(meta_path.read_text(encoding='utf-8'))
content = bin_path.read_bytes()
except Exception:
return None
finally:
meta_path.unlink(missing_ok=True)
bin_path.unlink(missing_ok=True)
if time.time() > meta['expires']:
return None
return {
'filename': meta['filename'],
'size': meta['size'],
'content': content,
}
def _evict_expired():
"""Delete any token files whose TTL has passed."""
now = time.time()
for meta_path in _STORE_DIR.glob('*.meta'):
try:
meta = json.loads(meta_path.read_text(encoding='utf-8'))
if now > meta['expires']:
meta_path.unlink(missing_ok=True)
(_STORE_DIR / meta_path.name.replace('.meta', '.bin')).unlink(missing_ok=True)
except Exception:
pass
def _content_disposition(filename: str) -> str:
"""
Build a Content-Disposition header safe for all HTTP clients.
Provides both:
- legacy filename= with non-ASCII stripped (latin-1 safe)
- RFC 5987 filename*= with full UTF-8 percent-encoding
Example:
attachment; filename="Lap_trinh_Python.pdf"; filename*=UTF-8''L%E1%BA%ADp%20tr%C3%ACnh%20Python.pdf
"""
ascii_name = filename.encode('ascii', 'ignore').decode('ascii')
ascii_name = ascii_name.replace('"', '_').replace('\\', '_') or 'download'
encoded_name = quote(filename, safe='.-_~')
return f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{encoded_name}"
def _mimetype_for(filename: str) -> str:
"""Return a sensible Content-Type for common ebook/document extensions."""
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
return {
'pdf': 'application/pdf',
'epub': 'application/epub+zip',
'mobi': 'application/x-mobipocket-ebook',
'azw': 'application/vnd.amazon.ebook',
'azw3': 'application/vnd.amazon.ebook',
'fb2': 'application/x-fictionbook+xml',
'djvu': 'image/vnd.djvu',
'txt': 'text/plain',
'doc': 'application/msword',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
}.get(ext, 'application/octet-stream')
class ScraperController:
"""
Controller owns all business logic:
- orchestrating login + scraping + download flows
- streaming individual files directly to the client via SSE + token fetch
- computing statistics
- formatting responses
The model is only called for browser/scraping primitives.
"""
_model = ZLibraryScraperModel()
# ------------------------------------------------------------------
# Routes
# ------------------------------------------------------------------
@classmethod
def index(cls):
return render_template('scraperZLibraryPages/scraperZLibraryMain.html')
@classmethod
def search_books(cls):
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'Invalid request data'}), 400
query = data.get('query', '').strip()
page_num = data.get('page', 1)
headless = data.get('headless', True)
if not query:
return jsonify({'success': False, 'error': 'Query is required'}), 400
if len(query) < 2:
return jsonify({'success': False, 'error': 'Query must be at least 2 characters'}), 400
try:
books, total_pages, total_books = cls._run_search(query, page_num, headless)
statistics = cls._calculate_statistics(books)
return jsonify({
'success': True,
'query': query,
'books': [b.to_dict() for b in books],
'statistics': statistics,
'total_count': len(books),
'current_page': page_num,
'total_pages': total_pages,
'total_books_count': total_books,
})
except Exception as e:
import traceback; traceback.print_exc()
return jsonify({'success': False, 'error': str(e), 'books': [], 'statistics': {}}), 500
@classmethod
def download_txt(cls):
data = request.get_json()
if not data:
return jsonify({'error': 'No results provided'}), 400
text_content = cls._format_results_as_text(data)
return Response(
text_content,
mimetype='text/plain',
headers={
'Content-Disposition': (
f'attachment;filename=zlib_{data.get("query", "search")}_results.txt'
)
},
)
@classmethod
def download_json(cls):
data = request.get_json()
if not data:
return jsonify({'error': 'No results provided'}), 400
return Response(
json.dumps(data, indent=2, ensure_ascii=False),
mimetype='application/json',
headers={
'Content-Disposition': (
f'attachment;filename=zlib_{data.get("query", "search")}_results.json'
)
},
)
@classmethod
def download_books_stream(cls):
"""
SSE endpoint. Opens one browser session, iterates through the requested
books, and for each book:
1. Downloads the file in the background thread.
2. Parks the bytes on disk under a one-time token (shared across all
gunicorn workers via the filesystem).
3. Emits a 'ready' SSE event with the token.
4. The frontend immediately triggers GET /api/download/file/<token>
which streams the file to the user with its native MIME type.
Events emitted (newline-delimited JSON after 'data: '):
{"type": "progress", "index": i, "total": n, "title": "..."}
{"type": "ready", "index": i, "total": n, "title": "...",
"token": "<hex>", "filename": "book.pdf"}
{"type": "error", "index": i, "total": n, "title": "...", "message": "..."}
{"type": "done", "total": n, "success": k, "failed": m}
A heartbeat comment (': heartbeat') is sent every 5 seconds while the
worker is busy but has not yet produced an event. This keeps Cloudflare
Tunnel (and any other idle-connection-killing proxy) from closing the
stream before the backend finishes downloading a book.
SSE comments are valid per spec and are silently ignored by all
EventSource / fetch-stream clients.
"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
books = data.get('books', [])
headless = data.get('headless', True)
if not books:
return jsonify({'error': 'No books provided'}), 400
q: queue.Queue = queue.Queue()
def _worker():
with sync_playwright() as p:
context = cls._model.create_context(p, headless)
try:
page, ok = cls._model.login(context)
if not ok:
print("⚠ Proceeding without confirmed login — downloads may fail")
success = failed = 0
for idx, book_data in enumerate(books, 1):
title = book_data.get('title', f'Book {idx}')
q.put({'type': 'progress', 'index': idx,
'total': len(books), 'title': title})
book_link = book_data.get('link', 'N/A')
if not book_link or book_link == 'N/A':
failed += 1
q.put({'type': 'error', 'index': idx, 'total': len(books),
'title': title, 'message': 'No link available'})
continue
try:
print(f"\n[{idx}/{len(books)}] Navigating to: {book_link}")
page.goto(book_link, timeout=30000)
page.wait_for_load_state('networkidle')
download_url, _ = cls._model.extract_download_info(page)
if download_url == 'N/A':
raise ValueError('Could not find download URL')
content = cls._model.download_file(page, context, download_url)
if not content:
raise ValueError('No file content received')
filename = cls._build_filename(book_data, idx)
token = _store_file(filename, content)
success += 1
print(f" ✅ Ready: {filename} ({len(content):,} bytes) — token: {token}")
q.put({'type': 'ready', 'index': idx, 'total': len(books),
'title': title, 'token': token, 'filename': filename})
except Exception as e:
failed += 1
print(f" ❌ Error: {e}")
q.put({'type': 'error', 'index': idx, 'total': len(books),
'title': title, 'message': str(e)})
q.put({'type': 'done', 'total': len(books),
'success': success, 'failed': failed})
finally:
context.close()
q.put(None) # sentinel — tells generator to stop
thread = threading.Thread(target=_worker, daemon=True)
thread.start()
def _generate():
"""
Yield SSE data frames from the worker queue.
Heartbeat strategy
------------------
q.get(timeout=5) blocks for up to 5 seconds waiting for the next
event. If nothing arrives within that window we emit an SSE comment
line (': heartbeat\\n\\n'). SSE comments are defined in the spec
(lines beginning with ':') and are completely ignored by all
compliant clients, but they DO transmit bytes over the wire.
Transmitting bytes resets the idle timer on Cloudflare Tunnels,
nginx proxy_read_timeout, and similar infrastructure that would
otherwise kill a connection that appears silent.
"""
while True:
try:
event = q.get(timeout=5)
except queue.Empty:
# No event yet — send a heartbeat to keep the tunnel alive
yield ': heartbeat\n\n'
continue
if event is None:
# Sentinel: worker finished
break
yield f'data: {json.dumps(event, ensure_ascii=False)}\n\n'
_evict_expired()
return Response(
_generate(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', # disable nginx buffering if present
},
)
@classmethod
def fetch_stored_file(cls, token: str):
"""
GET endpoint. Pops a previously stored file by token and streams it
to the client with the correct MIME type. One-time use — token is
consumed on first access.
"""
entry = _pop_file(token)
if not entry:
return jsonify({'error': 'File not found or expired'}), 404
mimetype = _mimetype_for(entry['filename'])
print(f"📤 Serving: {entry['filename']} ({entry['size']:,} bytes)")
return Response(
entry['content'],
mimetype=mimetype,
headers={
'Content-Disposition': _content_disposition(entry['filename']),
'Content-Length': str(entry['size']),
},
)
# Keep old route names as aliases so any existing calls still work
@classmethod
def download_books(cls):
return cls.download_books_stream()
download_books_zip = download_books
# ------------------------------------------------------------------
# Business logic — search
# ------------------------------------------------------------------
@classmethod
def _run_search(cls, query: str, page_num: int, headless: bool):
"""Open browser, login, scrape one results page, return raw data."""
encoded = query.replace(' ', '%20')
url = f"{cls._model.BASE_URL}/s/{encoded}?view=table"
if page_num > 1:
url += f"&page={page_num}"
with sync_playwright() as p:
context = cls._model.create_context(p, headless)
try:
page, ok = cls._model.login(context)
if not ok:
print("⚠ Proceeding without confirmed login")
print(f"Accessing: {url}")
page.goto(url, timeout=60000)
page.wait_for_load_state("networkidle")
total_pages = cls._model.get_total_pages(page)
total_books = cls._model.get_total_books_count(page)
print(f"Total pages available: {total_pages}")
print(f"Total books found: {total_books}")
try:
page.wait_for_selector('table.table_book tbody tr', timeout=10000)
except Exception:
print(f"No table found on page {page_num}")
return [], total_pages, total_books
books = cls._model.extract_books_from_table(page)
print(f"Found {len(books)} books on page {page_num}")
return books, total_pages, total_books
except Exception as e:
import traceback; traceback.print_exc()
raise
finally:
context.close()
# ------------------------------------------------------------------
# Business logic — helpers
# ------------------------------------------------------------------
@staticmethod
def _build_filename(book_data: Dict, idx: int) -> str:
"""Derive a safe ASCII filename from book metadata."""
raw_title = book_data.get('title', '')[:50]
# Normalize unicode → closest ASCII, then drop anything remaining non-ASCII
ascii_title = unicodedata.normalize('NFKD', raw_title)
ascii_title = ascii_title.encode('ascii', 'ignore').decode('ascii')
safe_title = re.sub(r'[^\w\s-]', '', ascii_title)
safe_title = re.sub(r'[-\s]+', '_', safe_title).strip('_') or f"book_{idx}"
extension = book_data.get('file', 'txt').split(',')[0].strip().lower()
if not extension or extension == 'n/a':
extension = 'txt'
return f"{safe_title}.{extension}"
@staticmethod
def _calculate_statistics(books) -> Dict:
"""Compute language/year/format distribution from a list of Book objects."""
if not books:
return {}
stats: Dict = {
'total_books': len(books),
'languages': {},
'years': {},
'formats': {},
}
for book in books:
lang = book.language
stats['languages'][lang] = stats['languages'].get(lang, 0) + 1
year = book.year
if year != 'N/A' and year.isdigit():
stats['years'][year] = stats['years'].get(year, 0) + 1
if book.file != 'N/A':
m = re.match(r'([a-zA-Z0-9]+)', book.file)
if m:
fmt = m.group(1).upper()
stats['formats'][fmt] = stats['formats'].get(fmt, 0) + 1
return stats
@classmethod
def _format_results_as_text(cls, results: Dict) -> str:
if not results.get('success'):
return f"Error: {results.get('error', 'Unknown error')}"
lines = [
"=" * 80,
"Z-Library Search Results",
"=" * 80,
f"Search Query: {results['query']}",
f"Total Books Found: {results.get('total_books_count', results['total_count'])}",
f"Books in this export: {results['total_count']}",
f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"=" * 80,
"",
]
for idx, book in enumerate(results['books'], 1):
authors_str = ', '.join(book.get('authors', [])) or 'N/A'
lines += [
f"Book #{idx}",
"-" * 60,
f"Title: {book['title']}",
f"Author(s): {authors_str}",
f"Publisher: {book.get('publisher', 'N/A')}",
f"Year: {book.get('year', 'N/A')}",
f"Pages: {book.get('pages', 'N/A')}",
f"Language: {book.get('language', 'N/A')}",
f"File: {book.get('file', 'N/A')}",
f"Link: {book.get('link', 'N/A')}",
]
if book.get('download_url') and book['download_url'] != 'N/A':
lines.append(f"Download: {book['download_url']}")
if book.get('file_size') and book['file_size'] != 'N/A':
lines.append(f"Size: {book['file_size']}")
lines += ["=" * 80, ""]
stats = results.get('statistics', {})
if stats:
total = results['total_count']
lines += ["", "=" * 80, "SUMMARY STATISTICS", "=" * 80,
f"Total Books: {stats.get('total_books', 0)}"]
if stats.get('languages'):
lines.append("\nLanguages:")
for lang, count in sorted(stats['languages'].items(), key=lambda x: -x[1]):
pct = count / total * 100 if total else 0
lines.append(f" {lang}: {count} ({pct:.1f}%)")
if stats.get('formats'):
lines.append("\nFormats:")
for fmt, count in sorted(stats['formats'].items(), key=lambda x: -x[1]):
pct = count / total * 100 if total else 0
lines.append(f" {fmt}: {count} ({pct:.1f}%)")
if stats.get('years'):
lines.append("\nPublication Years (Top 10):")
for year, count in sorted(stats['years'].items(), key=lambda x: -x[1])[:10]:
lines.append(f" {year}: {count}")
return "\n".join(lines)
+111
View File
@@ -0,0 +1,111 @@
# controllers/transcriptionController.py
import os
from werkzeug.utils import secure_filename
from flask import request, jsonify, send_file, render_template
from models.transcriptionModel import TranscriptionJob
from config import Config
class TranscriptionController:
"""Controller for handling transcription requests"""
@staticmethod
def index():
"""Render the main page"""
return render_template('transcriptionPages/transcriptionMain.html')
@staticmethod
def upload_chunk():
"""Handle chunk upload"""
if 'chunk' not in request.files:
return jsonify({'error': 'No chunk part'}), 400
chunk_file = request.files['chunk']
job_id = request.form.get('job_id')
chunk_index = int(request.form.get('chunk_index', 0))
total_chunks = int(request.form.get('total_chunks', 1))
filename = request.form.get('filename')
language = request.form.get('language', 'en')
model = request.form.get('model', 'medium')
if not job_id:
# First chunk - create new job
job = TranscriptionJob(filename, None, language, model)
job_id = job.id
job.total_chunks = total_chunks
else:
job = TranscriptionJob.get_job(job_id)
if not job:
return jsonify({'error': 'Job not found'}), 404
# Save chunk
job_chunks_folder = os.path.join(Config.CHUNKS_FOLDER, job_id)
os.makedirs(job_chunks_folder, exist_ok=True)
chunk_path = os.path.join(job_chunks_folder, f"chunk_{chunk_index}.mp3")
chunk_file.save(chunk_path)
# Update job progress
job.received_chunks = job.received_chunks + 1 if hasattr(job, 'received_chunks') else 1
# If all chunks received, start processing
if job.received_chunks == total_chunks:
job.start_processing()
return jsonify({
'job_id': job_id,
'chunk_index': chunk_index,
'received': job.received_chunks,
'total': total_chunks,
'status': 'processing' if job.received_chunks == total_chunks else 'uploading'
})
@staticmethod
def status(job_id):
"""Get job status"""
job = TranscriptionJob.get_job(job_id)
if not job:
return jsonify({'error': 'Job not found'}), 404
return jsonify(job.to_dict())
@staticmethod
def download_transcript(job_id):
"""Download transcript file"""
job = TranscriptionJob.get_job(job_id)
if not job:
return jsonify({'error': 'Job not found'}), 404
if job.status != 'completed':
return jsonify({'error': 'Transcription not ready'}), 400
return send_file(
job.transcript_path,
as_attachment=True,
download_name=f"transcript_{job.filename}.txt"
)
@staticmethod
def view_transcript(job_id):
"""View transcript in browser"""
job = TranscriptionJob.get_job(job_id)
if not job:
return "Job not found", 404
if job.status != 'completed':
return "Transcription not ready yet", 400
content = job.get_transcript_content()
if not content:
return "Transcript not found", 404
return render_template('transcriptionPages/transcriptionReview.html',
view='transcriptionPages',
content=content,
filename=job.filename,
job_id=job_id)
@staticmethod
def list_jobs():
"""List all jobs"""
jobs = TranscriptionJob.get_all_jobs()
return jsonify([job.to_dict() for job in jobs])
+359
View File
@@ -0,0 +1,359 @@
# models/scraperZLibraryModel.py
import re
import time
import os
import pathlib
import tempfile
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple
from playwright.sync_api import sync_playwright, Page, BrowserContext
PROFILE_DIR = str(pathlib.Path.home() / ".zlibrary_profile")
@dataclass
class Book:
title: str
authors: List[str] = field(default_factory=list)
publisher: str = 'N/A'
year: str = 'N/A'
pages: str = 'N/A'
language: str = 'N/A'
file: str = 'N/A'
link: str = 'N/A'
download_url: str = 'N/A'
file_size: str = 'N/A'
def to_dict(self) -> dict:
return {
'title': self.title,
'authors': self.authors,
'publisher': self.publisher,
'year': self.year,
'pages': self.pages,
'language': self.language,
'file': self.file,
'link': self.link,
'download_url': self.download_url,
'file_size': self.file_size
}
class ZLibraryScraperModel:
BASE_URL = "https://z-library.sk"
LOGIN_EMAIL = os.environ.get("ZLIBRARY_EMAIL")
LOGIN_PASSWORD = os.environ.get("ZLIBRARY_PASSWORD")
def create_context(self, playwright, headless: bool = True) -> BrowserContext:
return playwright.chromium.launch_persistent_context(
user_data_dir=PROFILE_DIR,
headless=headless,
viewport={'width': 1280, 'height': 800},
user_agent=(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/120.0.0.0 Safari/537.36'
),
accept_downloads=True,
)
def login(self, context: BrowserContext) -> Tuple[Page, bool]:
page = context.new_page()
try:
print("🔐 Attempting to login...")
page.goto(f"{self.BASE_URL}/login", timeout=30000)
page.wait_for_load_state("networkidle")
time.sleep(2)
if "/login" not in page.url:
print("✅ Already logged in (persistent session)")
return page, True
login_form = page.query_selector('#loginForm')
if not login_form:
print("⚠ Login form not found — assuming already authenticated")
return page, True
email_input = page.query_selector('input[name="email"]')
password_input = page.query_selector('input[name="password"]')
if not email_input or not password_input:
print("✗ Email or password input not found")
return page, False
email_input.fill(self.LOGIN_EMAIL)
print(f" ✓ Filled email: {self.LOGIN_EMAIL}")
password_input.fill(self.LOGIN_PASSWORD)
print(" ✓ Filled password")
submit_btn = (
page.query_selector('button[type="submit"][name="submit"]') or
page.query_selector('#loginForm button[type="submit"]') or
page.query_selector('button.btn-info')
)
if not submit_btn:
print("✗ Submit button not found")
return page, False
submit_btn.click()
page.wait_for_load_state("networkidle")
time.sleep(3)
print(f" URL after login: {page.url}")
if "/login" not in page.url:
print("✅ Login successful")
return page, True
error_el = page.query_selector('.form-error, .validation-error')
error_msg = error_el.text_content().strip() if error_el else "Unknown (still on login page)"
print(f" ✗ Login failed: {error_msg}")
return page, False
except Exception as e:
print(f"⚠ Login exception: {e}")
import traceback; traceback.print_exc()
return page, False
def extract_books_from_table(self, page: Page) -> List[Book]:
books = []
rows = page.query_selector_all('table.table_book tbody tr')
for row in rows:
try:
authors = [a.text_content().strip()
for a in row.query_selector_all('.authors a')]
title_el = row.query_selector('td:nth-child(2) > a')
if title_el:
title = ' '.join(title_el.text_content().strip().split())
link = title_el.get_attribute('href') or 'N/A'
if link != 'N/A' and not link.startswith('http'):
link = f"{self.BASE_URL}{link}"
else:
title, link = 'N/A', 'N/A'
def _cell_text(selector):
el = row.query_selector(selector)
return el.text_content().strip() if el else 'N/A'
publisher = _cell_text('td:nth-child(3) a')
year = _cell_text('td:nth-child(4)')
pages = _cell_text('td:nth-child(5)')
language = _cell_text('td:nth-child(6)')
file_info = _cell_text('td:nth-child(7) .book-property__extension')
books.append(Book(
title=title, authors=authors, publisher=publisher,
year=year, pages=pages, language=language,
file=file_info, link=link,
))
except Exception as e:
print(f"Error extracting row: {e}")
return books
def get_total_pages(self, page: Page) -> int:
try:
match = re.search(r'pagesTotal:\s*(\d+)', page.content())
if match:
return int(match.group(1))
nums = [
int(el.text_content().strip())
for el in page.query_selector_all('.paginator a, .paginator span')
if el.text_content().strip().isdigit()
]
return max(nums) if nums else 1
except Exception:
return 1
def get_total_books_count(self, page: Page) -> int:
try:
match = re.search(r'booksTotal:\s*(\d+)', page.content())
if match:
return int(match.group(1))
el = page.query_selector('.totalCount, .search-result-count')
if el:
nums = re.findall(r'\d+', el.text_content())
return int(nums[0]) if nums else 0
return 0
except Exception:
return 0
def extract_download_info(self, page: Page) -> Tuple[str, str]:
try:
btn = (
page.query_selector('a.addDownloadedBook') or
page.query_selector('a[class*="addDownloadedBook"]') or
page.query_selector('a[href*="/dl/"]')
)
if not btn:
return 'N/A', 'N/A'
href = btn.get_attribute('href') or ''
url = f"{self.BASE_URL}{href}" if href and not href.startswith('http') else href
text = btn.text_content().strip()
size_match = re.search(r'(\d+\.?\d*\s*(MB|KB|GB|B))', text, re.IGNORECASE)
size = size_match.group(1) if size_match else 'N/A'
ext_el = btn.query_selector('.book-property__extension')
ext = ext_el.text_content().strip() if ext_el else ''
if ext:
print(f" File extension: {ext}")
print(f" Found download URL: {url} size: {size}")
return url, size
except Exception as e:
print(f" Error extracting download info: {e}")
return 'N/A', 'N/A'
# Network-level error substrings that indicate a transient drop.
_RETRYABLE_ERRORS = (
"Timeout", # expect_download or goto timed out — server didn't respond
"ERR_CONNECTION_RESET",
"ERR_CONNECTION_CLOSED",
"ERR_TUNNEL_CONNECTION_FAILED",
"ERR_EMPTY_RESPONSE",
"net::ERR_",
"Target page, context or browser has been closed",
"Connection refused",
)
# Cloudflare error codes that mean "server temporarily unavailable, retry".
_RETRYABLE_CF_CODES = (b"522", b"523", b"524", b"525", b"526", b"530")
_MAX_RETRIES = 3
_RETRY_BACKOFF = (10, 20, 40) # seconds to wait before attempt 2, 3, 4 — longer for CF timeouts
@staticmethod
def _is_cloudflare_error_page(data: bytes) -> Optional[str]:
"""
Return a human-readable reason string if `data` is a Cloudflare error
HTML page (e.g. 522 Connection timed out), otherwise return None.
Cloudflare error pages always contain the string 'cloudflare' and one
of the known 5xx error codes inside a <title> or .code-label element.
We check the first 2 KB only — the full page can be ~10 KB but the
telltale markers are always near the top.
"""
head = data[:2048].lower()
if b"cloudflare" not in head:
return None
for code in ZLibraryScraperModel._RETRYABLE_CF_CODES:
if code in head:
return f"Cloudflare error {code.decode()} (server-side timeout)"
# Generic Cloudflare error page without a known code
if b"cf-error-details" in head or b"cf-wrapper" in head:
return "Cloudflare error page (unknown code)"
return None
def download_file(self, page: Page, context: BrowserContext, download_url: str) -> Optional[bytes]:
"""
Download a file given its direct dl URL.
Returns raw bytes on success, None on failure.
Two classes of retryable failure are handled:
1. Network-level exceptions (ERR_CONNECTION_RESET, tunnel failures, …)
— caught in the outer except block via _RETRYABLE_ERRORS substrings.
2. Cloudflare 5xx HTML pages returned as the file body (error 522, 523…)
— the download "succeeds" from Playwright's perspective but the bytes
are an HTML error page, not the real file. Detected by
_is_cloudflare_error_page() after save_as completes.
WHY the inner try/except around goto():
Playwright raises "Page.goto: Download is starting" as an EXCEPTION
even when expect_download() is correctly registered and listening.
This is a known Playwright quirk — the download event still fires
and dl_info.value is populated correctly. We swallow that specific
error and continue; anything else is re-raised.
"""
if not download_url or download_url == 'N/A':
return None
print(f" Download URL: {download_url}")
def _save_download(download_obj) -> Optional[bytes]:
suffix = os.path.splitext(download_obj.suggested_filename)[1] or '.bin'
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp_path = tmp.name
try:
download_obj.save_as(tmp_path)
return _read_and_delete(tmp_path)
except Exception as e:
print(f" save_as failed: {e}")
try:
pw_path = download_obj.path()
if pw_path and os.path.exists(pw_path):
return _read_and_delete(pw_path)
except Exception:
pass
return None
def _is_retryable_exception(err: Exception) -> bool:
msg = str(err)
return any(tag in msg for tag in self._RETRYABLE_ERRORS)
for attempt in range(1, self._MAX_RETRIES + 1):
dl_page = None
try:
dl_page = context.new_page()
with dl_page.expect_download(timeout=60000) as dl_info:
try:
dl_page.goto(download_url, wait_until="commit", timeout=60000)
except Exception as goto_err:
if "Download is starting" not in str(goto_err):
raise
data = _save_download(dl_info.value)
if not data:
# Empty file — not a transient error, give up immediately
print(" ✗ save_as returned no data")
return None
# Check whether we actually got a Cloudflare error HTML page
cf_reason = self._is_cloudflare_error_page(data)
if cf_reason:
if attempt < self._MAX_RETRIES:
delay = self._RETRY_BACKOFF[attempt - 1]
print(f" ⚠ Attempt {attempt}/{self._MAX_RETRIES}{cf_reason}")
print(f" Retrying in {delay}s...")
time.sleep(delay)
continue # next attempt
else:
print(f"{cf_reason} — all {self._MAX_RETRIES} attempts exhausted")
return None
attempt_tag = f" (attempt {attempt})" if attempt > 1 else ""
print(f" ✓ Downloaded {len(data):,} bytes{attempt_tag}")
return data
except Exception as e:
if _is_retryable_exception(e) and attempt < self._MAX_RETRIES:
delay = self._RETRY_BACKOFF[attempt - 1]
print(f" ⚠ Attempt {attempt}/{self._MAX_RETRIES} failed (network): {e}")
print(f" Retrying in {delay}s...")
time.sleep(delay)
else:
print(f" ✗ Download failed (attempt {attempt}/{self._MAX_RETRIES}): {e}")
return None
finally:
if dl_page:
try:
dl_page.close()
except Exception:
pass
return None
def _read_and_delete(path: str) -> bytes:
with open(path, 'rb') as f:
data = f.read()
try:
os.unlink(path)
except OSError:
pass
return data
+140
View File
@@ -0,0 +1,140 @@
# models/transcriptionModel.py
import os
import uuid
import whisper
import threading
from datetime import datetime
from pydub import AudioSegment
from config import Config
class TranscriptionJob:
"""Model for transcription job data and operations"""
# Class variable to store all jobs
_jobs = {}
_models = {}
def __init__(self, filename, filepath, language='en', model='medium'):
self.id = str(uuid.uuid4())
self.filename = filename
self.filepath = filepath
self.language = language
self.model = model
self.status = 'uploading' # New initial status
self.progress = 0
self.current_chunk = None
self.transcript_path = None
self.transcript_text = None
self.error = None
self.created_at = datetime.now().isoformat()
self.total_chunks = 0
self.received_chunks = 0
# Store in class dictionary
TranscriptionJob._jobs[self.id] = self
@classmethod
def get_model(cls, model_name='medium'):
"""Lazy load Whisper model with caching per model type"""
if model_name not in cls._models:
print(f"Loading Whisper model: {model_name}...")
device = "cpu"
cls._models[model_name] = whisper.load_model(model_name)
print(f"Model {model_name} loaded!")
return cls._models[model_name]
@classmethod
def get_job(cls, job_id):
"""Get a specific job by ID"""
return cls._jobs.get(job_id)
@classmethod
def get_all_jobs(cls):
"""Get all jobs"""
return list(cls._jobs.values())
@classmethod
def allowed_file(cls, filename):
"""Check if file extension is allowed"""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS
def to_dict(self):
"""Convert job to dictionary for JSON serialization"""
return {
'id': self.id,
'filename': self.filename,
'status': self.status,
'progress': self.progress,
'current_chunk': self.current_chunk,
'language': self.language,
'model': self.model,
'created_at': self.created_at,
'error': self.error,
'received_chunks': self.received_chunks,
'total_chunks': self.total_chunks
}
def process(self):
"""Process the transcription in background thread"""
try:
self.status = 'processing'
self.progress = 0
# Load the specific model for this job
model = self.get_model(self.model)
# Get all chunks from the chunks folder
job_chunks_folder = os.path.join(Config.CHUNKS_FOLDER, self.id)
chunks = []
# Sort chunks by index
for i in range(self.total_chunks):
chunk_file = os.path.join(job_chunks_folder, f"chunk_{i}.mp3")
if os.path.exists(chunk_file):
chunks.append(chunk_file)
# Transcribe chunks
final_text = ""
language_param = None if self.language == 'auto' else self.language
for idx, chunk_file in enumerate(chunks):
self.current_chunk = f"{idx+1}/{len(chunks)}"
result = model.transcribe(chunk_file, language=language_param)
final_text += result["text"] + "\n"
self.progress = int((idx + 1) / len(chunks) * 100)
# Save transcription
self.transcript_path = os.path.join(Config.TRANSCRIPTS_FOLDER, f"{self.id}.txt")
with open(self.transcript_path, "w", encoding="utf-8") as f:
f.write(final_text)
self.transcript_text = final_text[:500] + "..." if len(final_text) > 500 else final_text
# Clean up chunks
for chunk_file in chunks:
os.remove(chunk_file)
if os.path.exists(job_chunks_folder):
os.rmdir(job_chunks_folder)
self.status = 'completed'
self.progress = 100
except Exception as e:
self.status = 'error'
self.error = str(e)
print(f"Error in job {self.id}: {str(e)}")
def start_processing(self):
"""Start background processing thread"""
thread = threading.Thread(target=self.process)
thread.daemon = True
thread.start()
return self.id
def get_transcript_content(self):
"""Read transcript content from file"""
if self.transcript_path and os.path.exists(self.transcript_path):
with open(self.transcript_path, 'r', encoding='utf-8') as f:
return f.read()
return None
+8
View File
@@ -0,0 +1,8 @@
Flask
openai-whisper
pydub
werkzeug
python-dotenv
playwright
gunicorn
audioop-lts
+23
View File
@@ -0,0 +1,23 @@
# routes/scraperZLibraryRoute.py
from flask import Blueprint
from controllers.scraperZLibraryController import ScraperController
# Create blueprint
scraperZLibraryRoute = Blueprint('scraperZLibrary', __name__)
# Define routes
scraperZLibraryRoute.route('/', methods=['GET'])(ScraperController.index)
scraperZLibraryRoute.route('/api/search', methods=['POST'])(ScraperController.search_books)
scraperZLibraryRoute.route('/api/download/txt', methods=['POST'])(ScraperController.download_txt)
scraperZLibraryRoute.route('/api/download/json', methods=['POST'])(ScraperController.download_json)
scraperZLibraryRoute.route('/api/download/books', methods=['POST'])(ScraperController.download_books_zip) # New route
scraperZLibraryRoute.add_url_rule(
'/api/download/stream',
view_func=ScraperController.download_books_stream,
methods=['POST'],
)
scraperZLibraryRoute.add_url_rule(
'/api/download/file/<token>',
view_func=ScraperController.fetch_stored_file,
methods=['GET'],
)
+14
View File
@@ -0,0 +1,14 @@
# routes/transcriptionRoute.py
from flask import Blueprint
from controllers.transcriptionController import TranscriptionController
# Create blueprint
transcription_bp = Blueprint('transcription', __name__)
# Define routes
transcription_bp.route('/', methods=['GET'])(TranscriptionController.index)
transcription_bp.route('/upload-chunk', methods=['POST'])(TranscriptionController.upload_chunk)
transcription_bp.route('/status/<job_id>', methods=['GET'])(TranscriptionController.status)
transcription_bp.route('/transcript/<job_id>', methods=['GET'])(TranscriptionController.download_transcript)
transcription_bp.route('/view/<job_id>', methods=['GET'])(TranscriptionController.view_transcript)
transcription_bp.route('/jobs', methods=['GET'])(TranscriptionController.list_jobs)
+17
View File
@@ -0,0 +1,17 @@
<!-- static/header/header.html -->
<header class="app-header">
<nav class="header-nav">
<div class="nav-container">
<ul class="nav-links">
<li>
<a href="/" class="nav-link" data-nav="transcription">📝 Phiên Âm</a>
</li>
<li>
<a href="/scraperZLibrary" class="nav-link" data-nav="scraper"
>📚 Z-Library Scraper</a
>
</li>
</ul>
</div>
</nav>
</header>
+187
View File
@@ -0,0 +1,187 @@
// static/header/headerLoader.js
(function () {
"use strict";
// Configuration
const CONFIG = {
headerPath: "/static/header/header.html",
insertPosition: "afterbegin",
containerSelector: "body",
activeClass: "active",
version: "1.0",
};
// Styles
const styles = `
<style id="header-component-styles">
.app-header {
background: #fff;
border-bottom: 1px solid #e0e0e0;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
width: 100%;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.header-nav {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.nav-container {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
}
.nav-brand {
font-size: 18px;
font-weight: 500;
color: #111;
letter-spacing: -0.3px;
}
.nav-links {
display: flex;
list-style: none;
margin: 0;
padding: 0;
gap: 32px;
}
.nav-link {
text-decoration: none;
color: #444;
font-size: 15px;
font-weight: 400;
padding: 8px 0;
transition: all 0.15s ease;
border-bottom: 2px solid transparent;
}
.nav-link:hover {
color: #111;
border-bottom-color: #ccc;
}
.nav-link.active {
color: #111;
border-bottom-color: #111;
font-weight: 500;
}
/* Ensure content doesn't hide behind fixed header */
body {
padding-top: 64px !important;
margin: 0;
}
/* Mobile responsive */
@media (max-width: 600px) {
.nav-container {
flex-direction: column;
height: auto;
padding: 16px 0;
gap: 12px;
}
.nav-links {
gap: 24px;
}
body {
padding-top: 100px !important;
}
}
</style>
`;
// Load header function
async function loadHeader() {
try {
// Fetch header HTML
const response = await fetch(`${CONFIG.headerPath}?v=${CONFIG.version}`);
if (!response.ok) {
throw new Error(`Failed to load header: ${response.status}`);
}
const headerHTML = await response.text();
// Inject styles if not already present
if (!document.getElementById("header-component-styles")) {
document.head.insertAdjacentHTML("beforeend", styles);
}
// Insert header
const container = document.querySelector(CONFIG.containerSelector);
container.insertAdjacentHTML(CONFIG.insertPosition, headerHTML);
// Highlight active link
highlightActiveLink();
console.log("✅ Header component loaded v" + CONFIG.version);
} catch (error) {
console.error("❌ Error loading header:", error);
createFallbackHeader();
}
}
// Highlight current page link
function highlightActiveLink() {
const currentPath = window.location.pathname;
const links = document.querySelectorAll(".nav-link");
links.forEach((link) => {
const href = link.getAttribute("href");
// Check if current path matches
if (
currentPath === href ||
(href !== "/" && currentPath.startsWith(href))
) {
link.classList.add(CONFIG.activeClass);
}
// Special case for root
if (currentPath === "/" && href === "/transcription") {
link.classList.add(CONFIG.activeClass);
}
});
}
// Fallback header in case of error
function createFallbackHeader() {
const fallback = document.createElement("div");
fallback.style.cssText = `
padding: 15px 20px;
background: #fff;
border-bottom: 1px solid #e0e0e0;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
`;
fallback.innerHTML = `
<div style="max-width: 1200px; margin: 0 auto; display: flex; gap: 30px;">
<a href="/" style="color: #111; text-decoration: none;">📝 Phiên Âm</a>
<a href="/scraperZLibrary" style="color: #111; text-decoration: none;">📚 Z-Library Scraper</a>
</div>
`;
document.body.insertAdjacentElement("afterbegin", fallback);
document.body.style.paddingTop = "50px";
}
// Initialize when DOM is ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", loadHeader);
} else {
loadHeader();
}
})();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,629 @@
<!-- views/transcriptionPages/transcriptionMain.html -->
<!doctype html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Phiên Âm</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
min-height: 100vh;
padding: 40px 20px;
color: #111;
}
.container {
max-width: 760px;
margin: 0 auto;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 48px;
}
h1 {
font-size: 22px;
font-weight: 500;
letter-spacing: -0.3px;
margin-bottom: 6px;
color: #111;
}
.subtitle {
font-size: 14px;
color: #777;
margin-bottom: 36px;
}
.upload-view {
display: block;
}
.transcript-view {
display: none;
}
.upload-area {
border: 1.5px dashed #ccc;
border-radius: 4px;
padding: 48px 32px;
text-align: center;
cursor: pointer;
transition:
border-color 0.2s,
background 0.2s;
margin-bottom: 24px;
}
.upload-area:hover,
.upload-area.dragover {
border-color: #111;
background: #fafafa;
}
.upload-icon {
font-size: 22px;
margin-bottom: 14px;
display: block;
}
.upload-area h3 {
font-size: 15px;
font-weight: 500;
margin-bottom: 6px;
color: #222;
}
.upload-area p {
font-size: 13px;
color: #999;
}
.file-input {
display: none;
}
.language-select {
display: flex;
gap: 24px;
margin-bottom: 24px;
font-size: 14px;
color: #444;
}
.language-select label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.language-select input[type="radio"] {
accent-color: #111;
}
.model-select {
margin-bottom: 24px;
font-size: 14px;
color: #444;
}
.model-select select {
margin-left: 10px;
padding: 6px 10px;
border: 1px solid #ccc;
border-radius: 3px;
font-size: 14px;
}
.btn {
background: #111;
color: #fff;
border: none;
padding: 10px 24px;
border-radius: 3px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
margin: 4px 4px 4px 0;
text-decoration: none;
display: inline-block;
}
.btn:hover {
background: #333;
}
.btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.btn-outline {
background: #fff;
color: #111;
border: 1px solid #ccc;
}
.btn-outline:hover {
background: #f5f5f5;
border-color: #999;
}
.file-info {
background: #f9f9f9;
border: 1px solid #eee;
padding: 14px 16px;
border-radius: 3px;
margin-bottom: 20px;
font-size: 13px;
color: #555;
line-height: 1.8;
word-break: break-all;
display: none;
}
.progress-container {
margin-top: 28px;
display: none;
}
.progress-container h3 {
font-size: 14px;
font-weight: 500;
color: #444;
margin-bottom: 10px;
}
.progress-bar {
width: 100%;
height: 3px;
background: #eee;
border-radius: 2px;
overflow: hidden;
margin-bottom: 10px;
}
.progress-fill {
height: 100%;
background: #111;
width: 0%;
transition: width 0.3s ease;
}
.upload-progress-bar {
width: 100%;
height: 3px;
background: #e0e0e0;
border-radius: 2px;
overflow: hidden;
margin: 10px 0;
}
.upload-progress-fill {
height: 100%;
background: #4caf50;
width: 0%;
transition: width 0.3s ease;
}
.progress-text {
margin-top: 8px;
font-size: 13px;
color: #888;
}
.result-area {
margin-top: 28px;
display: none;
border-top: 1px solid #eee;
padding-top: 24px;
}
.result-label {
font-size: 13px;
font-weight: 500;
color: #111;
margin-bottom: 12px;
}
.result-preview {
background: #f9f9f9;
border: 1px solid #eee;
border-radius: 3px;
padding: 16px;
font-size: 13px;
color: #444;
line-height: 1.7;
margin-bottom: 16px;
}
.meta {
font-size: 13px;
color: #666;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
line-height: 1.8;
}
.transcript-content {
background: #f9f9f9;
border: 1px solid #eee;
border-radius: 3px;
padding: 20px;
font-size: 14px;
line-height: 1.8;
white-space: pre-wrap;
word-wrap: break-word;
max-height: 560px;
overflow-y: auto;
color: #333;
}
.nav-buttons {
margin-bottom: 24px;
}
</style>
</head>
<body>
<div class="container">
<div class="upload-view">
<h1>Phiên Âm Whisper</h1>
<p class="subtitle">
Tải lên tệp video hoặc âm thanh để nhận bản phiên âm bằng AI (không
giới hạn kích thước)
</p>
<div class="upload-area" id="uploadArea">
<span class="upload-icon"></span>
<h3>Nhấp hoặc kéo &amp; thả tệp vào đây</h3>
<p>
Hỗ trợ MP4, AVI, MOV, MKV, MP3, WAV, M4A (không giới hạn kích thước)
</p>
<input
type="file"
id="fileInput"
class="file-input"
accept=".mp4,.avi,.mov,.mkv,.mp3,.wav,.m4a"
/>
</div>
<div class="language-select">
<label
><input type="radio" name="language" value="en" checked /> Tiếng
Anh</label
>
<label
><input type="radio" name="language" value="vi" /> Tiếng Việt</label
>
<label
><input type="radio" name="language" value="auto" /> Tự động nhận
diện</label
>
</div>
<div class="model-select">
<label
>Model Whisper:
<select id="modelSelect">
<option value="tiny">Tiny (nhanh nhất, độ chính xác thấp)</option>
<option value="base">
Base (nhanh, độ chính xác trung bình)
</option>
<option value="small">Small (cân bằng)</option>
<option value="medium" selected>
Medium (độ chính xác cao, chậm hơn)
</option>
<option value="large">
Large (độ chính xác cao nhất, chậm nhất)
</option>
</select>
</label>
</div>
<div id="fileInfo" class="file-info"></div>
<button class="btn" id="uploadBtn" style="display: none">
Bắt Đầu Phiên Âm
</button>
<div class="progress-container" id="progressContainer">
<h3>Đang tải lên và xử lý...</h3>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="upload-progress-bar">
<div class="upload-progress-fill" id="uploadProgressFill"></div>
</div>
<div class="progress-text" id="progressText">Đang khởi động...</div>
</div>
<div class="result-area" id="resultArea">
<div class="result-label">Phiên âm hoàn tất</div>
<div class="result-preview" id="transcriptPreview"></div>
<button class="btn" id="viewFullBtn">Xem Toàn Bộ</button>
<button class="btn btn-outline" id="downloadBtn">
Tải Xuống TXT
</button>
</div>
</div>
<div class="transcript-view">
<h1>Kết Quả Phiên Âm</h1>
<div class="meta">
<strong>Tệp:</strong> {{ filename }}<br />
<strong>Mã công việc:</strong> {{ job_id }}
</div>
<div class="nav-buttons">
<a href="/" class="btn btn-outline">← Quay lại</a>
<a href="/transcript/{{ job_id }}" class="btn">Tải Xuống TXT</a>
</div>
<div class="transcript-content">{{ content }}</div>
</div>
</div>
<script>
if (document.querySelector(".upload-view").style.display !== "none") {
const uploadArea = document.getElementById("uploadArea");
const fileInput = document.getElementById("fileInput");
const uploadBtn = document.getElementById("uploadBtn");
const fileInfo = document.getElementById("fileInfo");
const progressContainer = document.getElementById("progressContainer");
const progressFill = document.getElementById("progressFill");
const uploadProgressFill =
document.getElementById("uploadProgressFill");
const progressText = document.getElementById("progressText");
const resultArea = document.getElementById("resultArea");
const transcriptPreview = document.getElementById("transcriptPreview");
let selectedFile = null;
uploadArea.addEventListener("click", () => fileInput.click());
uploadArea.addEventListener("dragover", (e) => {
e.preventDefault();
uploadArea.classList.add("dragover");
});
uploadArea.addEventListener("dragleave", () =>
uploadArea.classList.remove("dragover"),
);
uploadArea.addEventListener("drop", (e) => {
e.preventDefault();
uploadArea.classList.remove("dragover");
if (e.dataTransfer.files.length > 0)
handleFileSelect(e.dataTransfer.files[0]);
});
fileInput.addEventListener("change", (e) => {
if (e.target.files.length > 0) handleFileSelect(e.target.files[0]);
});
function handleFileSelect(file) {
selectedFile = file;
const sizeInMB = (file.size / (1024 * 1024)).toFixed(2);
fileInfo.style.display = "block";
fileInfo.innerHTML = `<strong>Tệp đã chọn:</strong> ${file.name}<br><strong>Kích thước:</strong> ${sizeInMB} MB`;
uploadBtn.style.display = "inline-block";
}
uploadBtn.addEventListener("click", async () => {
if (!selectedFile) return;
const language = document.querySelector(
'input[name="language"]:checked',
).value;
const model = document.getElementById("modelSelect").value;
uploadBtn.disabled = true;
progressContainer.style.display = "block";
resultArea.style.display = "none";
try {
await uploadFileInChunks(selectedFile, language, model);
} catch (error) {
alert("Tải lên thất bại: " + error.message);
uploadBtn.disabled = false;
progressContainer.style.display = "none";
}
});
async function uploadFileInChunks(file, language, model) {
const CHUNK_SIZE = 3 * 60 * 1000; // 3 minutes in milliseconds
let jobId = null;
// Create audio context to get duration
const audioContext = new (
window.AudioContext || window.webkitAudioContext
)();
const arrayBuffer = await file.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const duration = audioBuffer.duration * 1000; // Duration in milliseconds
const totalChunks = Math.ceil(duration / CHUNK_SIZE);
progressText.textContent = `Đang chuẩn bị tải lên ${totalChunks} phần...`;
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, duration);
progressText.textContent = `Đang tải lên phần ${i + 1}/${totalChunks}...`;
// Extract chunk from audio
const chunkBlob = await extractAudioChunk(
audioBuffer,
start,
end,
file.type,
);
const formData = new FormData();
formData.append("chunk", chunkBlob, `chunk_${i}.mp3`);
formData.append("chunk_index", i);
formData.append("total_chunks", totalChunks);
formData.append("filename", file.name);
formData.append("language", language);
formData.append("model", model);
if (jobId) {
formData.append("job_id", jobId);
}
const response = await fetch("/upload-chunk", {
method: "POST",
body: formData,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Upload failed");
}
jobId = data.job_id;
// Update upload progress
const uploadProgress = ((i + 1) / totalChunks) * 100;
uploadProgressFill.style.width = uploadProgress + "%";
if (data.status === "processing") {
progressText.textContent =
"Tất cả các phần đã được tải lên! Đang bắt đầu phiên âm...";
startPolling(jobId);
return;
}
}
}
async function extractAudioChunk(
audioBuffer,
startTime,
endTime,
mimeType,
) {
const sampleRate = audioBuffer.sampleRate;
const startSample = Math.floor((startTime * sampleRate) / 1000);
const endSample = Math.floor((endTime * sampleRate) / 1000);
const frameCount = endSample - startSample;
// Create new audio buffer for chunk
const chunkBuffer = new AudioContext().createBuffer(
audioBuffer.numberOfChannels,
frameCount,
sampleRate,
);
// Copy data
for (
let channel = 0;
channel < audioBuffer.numberOfChannels;
channel++
) {
const channelData = audioBuffer.getChannelData(channel);
const chunkData = chunkBuffer.getChannelData(channel);
for (let i = 0; i < frameCount; i++) {
chunkData[i] = channelData[startSample + i];
}
}
// Convert to WAV first, then let server handle conversion
const wavBlob = audioBufferToWav(chunkBuffer);
// If it's already an audio file, return as is, otherwise convert
if (mimeType.includes("audio")) {
return wavBlob;
} else {
// For video files, we'll send the audio portion
return wavBlob;
}
}
function audioBufferToWav(buffer) {
const numChannels = buffer.numberOfChannels;
const sampleRate = buffer.sampleRate;
const format = 1; // PCM
const bitDepth = 16;
let resultBuffer;
if (numChannels === 2) {
resultBuffer = interleave(
buffer.getChannelData(0),
buffer.getChannelData(1),
);
} else {
resultBuffer = buffer.getChannelData(0);
}
const dataLength = resultBuffer.length * 2;
const bufferLength = 44 + dataLength;
const arrayBuffer = new ArrayBuffer(bufferLength);
const view = new DataView(arrayBuffer);
// Write WAV header
writeString(view, 0, "RIFF");
view.setUint32(4, 36 + dataLength, true);
writeString(view, 8, "WAVE");
writeString(view, 12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, format, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 4, true);
view.setUint16(32, numChannels * 2, true);
view.setUint16(34, bitDepth, true);
writeString(view, 36, "data");
view.setUint32(40, dataLength, true);
// Write audio data
const offset = 44;
for (let i = 0; i < resultBuffer.length; i++) {
const sample = Math.max(-1, Math.min(1, resultBuffer[i]));
view.setInt16(
offset + i * 2,
sample < 0 ? sample * 0x8000 : sample * 0x7fff,
true,
);
}
return new Blob([arrayBuffer], { type: "audio/wav" });
}
function writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
function interleave(left, right) {
const length = left.length + right.length;
const result = new Float32Array(length);
let inputIndex = 0;
for (let i = 0; i < length; i += 2) {
result[i] = left[inputIndex];
result[i + 1] = right[inputIndex];
inputIndex++;
}
return result;
}
function startPolling(jobId) {
const pollInterval = setInterval(async () => {
try {
const data = await (await fetch(`/status/${jobId}`)).json();
progressFill.style.width = data.progress + "%";
if (data.status === "processing") {
progressText.textContent = data.current_chunk
? `Đang phiên âm đoạn ${data.current_chunk}...`
: `Đang xử lý: ${data.progress}%`;
} else if (data.status === "completed") {
clearInterval(pollInterval);
progressText.textContent = "Hoàn tất!";
showResult(jobId);
} else if (data.status === "error") {
clearInterval(pollInterval);
progressText.textContent = "Lỗi: " + data.error;
uploadBtn.disabled = false;
}
} catch (e) {
console.error("Lỗi kiểm tra:", e);
}
}, 2000);
}
async function showResult(jobId) {
try {
const html = await (await fetch(`/view/${jobId}`)).text();
const doc = new DOMParser().parseFromString(html, "text/html");
const content =
doc.querySelector(".transcript-content")?.textContent ||
"Đã tải bản phiên âm";
transcriptPreview.innerHTML = `<pre style="white-space:pre-wrap;word-wrap:break-word;margin:0;font-family:inherit">${content.substring(0, 300)}...</pre>`;
resultArea.style.display = "block";
uploadBtn.disabled = false;
document.getElementById("viewFullBtn").onclick = () =>
window.open(`/view/${jobId}`, "_blank");
document.getElementById("downloadBtn").onclick = () =>
(window.location.href = `/transcript/${jobId}`);
} catch (e) {
console.error("Lỗi hiển thị:", e);
}
}
}
</script>
<script src="/static/header/headerLoader.js?v=1.0"></script>
</body>
</html>
@@ -0,0 +1,79 @@
<!-- views/transcriptionPages/transcriptionReview.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transcript - {{ filename }}</title>
<style>
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 10px;
padding: 30px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
color: #333;
margin-bottom: 10px;
}
.meta {
color: #666;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.transcript {
background: #f9f9f9;
padding: 20px;
border-radius: 8px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
}
.btn {
background: #000000;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
text-decoration: none;
display: inline-block;
margin-right: 10px;
}
.btn:hover {
background: #000000;
}
</style>
</head>
<body>
<div class="container">
<h1>Kết quả phiên âm</h1>
<div class="meta">
<strong>File:</strong> {{ filename }}<br />
<strong>Job ID:</strong> {{ job_id }}
</div>
<div style="margin-bottom: 20px">
<a href="/" class="btn">← Về</a>
<a href="/transcript/{{ job_id }}" class="btn">Tải TXT</a>
</div>
<div class="transcript">{{ content }}</div>
</div>
</body>
</html>