From 6e42f05b55eb0d137ff445a37f19eda004468c07 Mon Sep 17 00:00:00 2001 From: MinhQuan Date: Mon, 15 Jun 2026 01:06:05 +0700 Subject: [PATCH] init --- .gitignore | 19 + app.py | 28 + config.py | 32 + controllers/scraperZLibraryController.py | 534 +++++++ controllers/transcriptionController.py | 111 ++ models/scraperZLibraryModel.py | 359 +++++ models/transcriptionModel.py | 140 ++ requirements.txt | 8 + routes/scraperZLibraryRoute.py | 23 + routes/transcriptionRoute.py | 14 + static/header/header.html | 17 + static/header/headerLoader.js | 187 +++ .../scraperZLibraryMain.html | 1230 +++++++++++++++++ .../transcriptionPages/transcriptionMain.html | 629 +++++++++ .../transcriptionReview.html | 79 ++ 15 files changed, 3410 insertions(+) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 config.py create mode 100644 controllers/scraperZLibraryController.py create mode 100644 controllers/transcriptionController.py create mode 100644 models/scraperZLibraryModel.py create mode 100644 models/transcriptionModel.py create mode 100644 requirements.txt create mode 100644 routes/scraperZLibraryRoute.py create mode 100644 routes/transcriptionRoute.py create mode 100644 static/header/header.html create mode 100644 static/header/headerLoader.js create mode 100644 views/scraperZLibraryPages/scraperZLibraryMain.html create mode 100644 views/transcriptionPages/transcriptionMain.html create mode 100644 views/transcriptionPages/transcriptionReview.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3269cec --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..25f7c6a --- /dev/null +++ b/app.py @@ -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) \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..c6aa921 --- /dev/null +++ b/config.py @@ -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) \ No newline at end of file diff --git a/controllers/scraperZLibraryController.py b/controllers/scraperZLibraryController.py new file mode 100644 index 0000000..4840605 --- /dev/null +++ b/controllers/scraperZLibraryController.py @@ -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: +# .bin — raw file bytes +# .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/ + 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": "", "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) \ No newline at end of file diff --git a/controllers/transcriptionController.py b/controllers/transcriptionController.py new file mode 100644 index 0000000..4d400e7 --- /dev/null +++ b/controllers/transcriptionController.py @@ -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]) \ No newline at end of file diff --git a/models/scraperZLibraryModel.py b/models/scraperZLibraryModel.py new file mode 100644 index 0000000..20621ec --- /dev/null +++ b/models/scraperZLibraryModel.py @@ -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 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 \ No newline at end of file diff --git a/models/transcriptionModel.py b/models/transcriptionModel.py new file mode 100644 index 0000000..72fa026 --- /dev/null +++ b/models/transcriptionModel.py @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2f3a69d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +Flask +openai-whisper +pydub +werkzeug +python-dotenv +playwright +gunicorn +audioop-lts \ No newline at end of file diff --git a/routes/scraperZLibraryRoute.py b/routes/scraperZLibraryRoute.py new file mode 100644 index 0000000..da86e65 --- /dev/null +++ b/routes/scraperZLibraryRoute.py @@ -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'], +) \ No newline at end of file diff --git a/routes/transcriptionRoute.py b/routes/transcriptionRoute.py new file mode 100644 index 0000000..88c82e9 --- /dev/null +++ b/routes/transcriptionRoute.py @@ -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) \ No newline at end of file diff --git a/static/header/header.html b/static/header/header.html new file mode 100644 index 0000000..608fd00 --- /dev/null +++ b/static/header/header.html @@ -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> diff --git a/static/header/headerLoader.js b/static/header/headerLoader.js new file mode 100644 index 0000000..8cecb41 --- /dev/null +++ b/static/header/headerLoader.js @@ -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(); + } +})(); diff --git a/views/scraperZLibraryPages/scraperZLibraryMain.html b/views/scraperZLibraryPages/scraperZLibraryMain.html new file mode 100644 index 0000000..21a3c29 --- /dev/null +++ b/views/scraperZLibraryPages/scraperZLibraryMain.html @@ -0,0 +1,1230 @@ +<!-- views/scraperZLibraryPages/scraperZLibraryMain.html --> +<!doctype html> +<html lang="vi"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Tìm Kiếm Sách Z-Library + + + +
+
+

Z-Library

+

Tìm kiếm và tải sách từ thư viện Z-Library

+
+ +
+
+
+ + +
+ +
+ +
+
+ +
+ +
+
+ +
+ + + + +
+
+
+ 📚 +
+

Bắt đầu tìm kiếm

+

Nhập từ khóa để tìm sách từ Z-Library

+
+
+
+
+ + + + + diff --git a/views/transcriptionPages/transcriptionMain.html b/views/transcriptionPages/transcriptionMain.html new file mode 100644 index 0000000..e035009 --- /dev/null +++ b/views/transcriptionPages/transcriptionMain.html @@ -0,0 +1,629 @@ + + + + + + + Phiên Âm + + + +
+
+

Phiên Âm Whisper

+

+ 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) +

+ +
+ +

Nhấp hoặc kéo & thả tệp vào đây

+

+ Hỗ trợ MP4, AVI, MOV, MKV, MP3, WAV, M4A (không giới hạn kích thước) +

+ +
+ +
+ + + +
+ +
+ +
+ +
+ + +
+

Đang tải lên và xử lý...

+
+
+
+
+
+
+
Đang khởi động...
+
+ +
+
Phiên âm hoàn tất
+
+ + +
+
+ +
+

Kết Quả Phiên Âm

+
+ Tệp: {{ filename }}
+ Mã công việc: {{ job_id }} +
+ +
{{ content }}
+
+
+ + + + + diff --git a/views/transcriptionPages/transcriptionReview.html b/views/transcriptionPages/transcriptionReview.html new file mode 100644 index 0000000..6c6aff1 --- /dev/null +++ b/views/transcriptionPages/transcriptionReview.html @@ -0,0 +1,79 @@ + + + + + + + Transcript - {{ filename }} + + + +
+

Kết quả phiên âm

+
+ File: {{ filename }}
+ Job ID: {{ job_id }} +
+ + + +
{{ content }}
+
+ +