forked from MinhQuan/MiscCorpo
init
This commit is contained in:
+19
@@ -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/
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
Flask
|
||||
openai-whisper
|
||||
pydub
|
||||
werkzeug
|
||||
python-dotenv
|
||||
playwright
|
||||
gunicorn
|
||||
audioop-lts
|
||||
@@ -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'],
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
Reference in New Issue
Block a user