From a8813e57da92aba86729050303bc6271b082295b Mon Sep 17 00:00:00 2001 From: lelo Date: Sun, 16 Mar 2025 19:54:47 +0100 Subject: [PATCH] initial commit --- .gitignore | 1 + app.py | 237 ++++++++++++++++++++++++++++++++++++++++ apply_correction.py | 48 +++++++++ check_ssh_tunnel.sh | 19 ++++ docker-compose.yml | 29 +++++ error_correction.json | 29 +++++ static/app.js | 182 +++++++++++++++++++++++++++++++ static/gallery.css | 84 +++++++++++++++ static/gallery.js | 204 +++++++++++++++++++++++++++++++++++ static/logo.png | Bin 0 -> 187229 bytes static/manifest.json | 21 ++++ static/styles.css | 210 ++++++++++++++++++++++++++++++++++++ static/sw.js | 26 +++++ templates/browse.html | 74 +++++++++++++ templates/error.html | 16 +++ transcribe_all.py | 245 ++++++++++++++++++++++++++++++++++++++++++ 16 files changed, 1425 insertions(+) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 apply_correction.py create mode 100644 check_ssh_tunnel.sh create mode 100644 docker-compose.yml create mode 100644 error_correction.json create mode 100644 static/app.js create mode 100644 static/gallery.css create mode 100644 static/gallery.js create mode 100644 static/logo.png create mode 100644 static/manifest.json create mode 100644 static/styles.css create mode 100644 static/sw.js create mode 100644 templates/browse.html create mode 100644 templates/error.html create mode 100644 transcribe_all.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a979ee7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/venv \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..5b541bc --- /dev/null +++ b/app.py @@ -0,0 +1,237 @@ +from flask import Flask, render_template, send_file, url_for, jsonify, request, session, send_from_directory +import os +from PIL import Image +import io +from functools import wraps +import mimetypes +from datetime import datetime +from urllib.parse import unquote +import diskcache +cache = diskcache.Cache('./filecache', size_limit= 124**3) # 124MB limit + +app = Flask(__name__) + +# Use a raw string for the UNC path. +app.config['MP3_ROOT'] = r'\\192.168.10.10\docker2\sync-bethaus\syncfiles\folders' +app.config['SECRET_KEY'] = os.urandom(24) +app.config['ALLOWED_SECRETS'] = { + 'test': datetime(2026, 3, 31, 23, 59, 59), + 'another_secret': datetime(2024, 4, 30, 23, 59, 59) +} + +def require_secret(f): + @wraps(f) + def decorated_function(*args, **kwargs): + allowed_secrets = app.config['ALLOWED_SECRETS'] + current_secret = session.get('secret') + now = datetime.now() + + def is_valid(secret): + expiry_date = allowed_secrets.get(secret) + return expiry_date and now <= expiry_date + + # Check if secret from session is still valid + if current_secret and is_valid(current_secret): + return f(*args, **kwargs) + + # Check secret from GET parameter + secret = request.args.get('secret') + if secret and is_valid(secret): + session['secret'] = secret + return f(*args, **kwargs) + + # If secret invalid or expired + return render_template('error.html', message="Invalid or expired secret."), 403 + return decorated_function + +@app.route('/static/icons/.png') +def serve_resized_icon(size): + dimensions = tuple(map(int, size.split('-')[1].split('x'))) + original_logo_path = os.path.join(app.root_path, 'static', 'logo.png') + + with Image.open(original_logo_path) as img: + img = img.convert("RGBA") + + # Original image dimensions + orig_width, orig_height = img.size + + # Check if requested dimensions exceed original dimensions + if dimensions[0] >= orig_width and dimensions[1] >= orig_height: + # Requested size is larger or equal; return original image + return send_file(original_logo_path, mimetype='image/png') + + # Otherwise, resize + resized_img = img.copy() + resized_img.thumbnail(dimensions, Image.LANCZOS) + + # Save resized image to bytes + resized_bytes = io.BytesIO() + resized_img.save(resized_bytes, format='PNG') + resized_bytes.seek(0) + + return send_file(resized_bytes, mimetype='image/png') + +@app.route('/sw.js') +def serve_sw(): + return send_from_directory(os.path.join(app.root_path, 'static'), 'sw.js', mimetype='application/javascript') + +def list_directory_contents(directory, subpath): + """ + List only the immediate contents of the given directory. + Also, if a "Transkription" subfolder exists, check for matching .md files for music files. + Skip folders that start with a dot. + """ + directories = [] + files = [] + transcription_dir = os.path.join(directory, "Transkription") + transcription_exists = os.path.isdir(transcription_dir) + + # Define allowed file extensions. + allowed_music_exts = ('.mp3',) + allowed_image_exts = ('.jpg', '.jpeg', '.png', '.gif', '.bmp') + + try: + for item in sorted(os.listdir(directory)): + # Skip hidden folders and files starting with a dot. + if item.startswith('.'): + continue + + full_path = os.path.join(directory, item) + # Process directories. + if os.path.isdir(full_path): + # Also skip the "Transkription" folder. + if item.lower() == "transkription": + continue + rel_path = os.path.join(subpath, item) if subpath else item + rel_path = rel_path.replace(os.sep, '/') + directories.append({'name': item, 'path': rel_path}) + # Process files: either music or image files. + elif os.path.isfile(full_path) and ( + item.lower().endswith(allowed_music_exts) or item.lower().endswith(allowed_image_exts) + ): + rel_path = os.path.join(subpath, item) if subpath else item + rel_path = rel_path.replace(os.sep, '/') + + # Determine the file type. + if item.lower().endswith(allowed_music_exts): + file_type = 'music' + else: + file_type = 'image' + + file_entry = {'name': item, 'path': rel_path, 'file_type': file_type} + + # Only check for transcription if it's a music file. + if file_type == 'music' and transcription_exists: + base_name = os.path.splitext(item)[0] + transcript_filename = base_name + '.md' + transcript_path = os.path.join(transcription_dir, transcript_filename) + if os.path.isfile(transcript_path): + file_entry['has_transcript'] = True + transcript_rel_path = os.path.join(subpath, "Transkription", transcript_filename) if subpath else os.path.join("Transkription", transcript_filename) + transcript_rel_path = transcript_rel_path.replace(os.sep, '/') + file_entry['transcript_url'] = url_for('get_transcript', filename=transcript_rel_path) + else: + file_entry['has_transcript'] = False + else: + file_entry['has_transcript'] = False + files.append(file_entry) + except PermissionError: + pass + return directories, files + + +def generate_breadcrumbs(subpath): + breadcrumbs = [{'name': 'Home', 'path': ''}] + if subpath: + parts = subpath.split('/') + path_accum = "" + for part in parts: + path_accum = f"{path_accum}/{part}" if path_accum else part + breadcrumbs.append({'name': part, 'path': path_accum}) + return breadcrumbs + +# API endpoint for AJAX: returns JSON for a given directory. +@app.route('/api/path/', defaults={'subpath': ''}) +@app.route('/api/path/') +@require_secret +def api_browse(subpath): + mp3_root = app.config['MP3_ROOT'] + directory = os.path.join(mp3_root, subpath.replace('/', os.sep)) + + if not os.path.isdir(directory): + return jsonify({'error': 'Directory not found'}), 404 + + directories, files = list_directory_contents(directory, subpath) + breadcrumbs = generate_breadcrumbs(subpath) + + return jsonify({ + 'breadcrumbs': breadcrumbs, + 'directories': directories, + 'files': files + }) + +@app.route("/media/") +@require_secret +def serve_file(filename): + decoded_filename = unquote(filename).replace('/', os.sep) + full_path = os.path.normpath(os.path.join(app.config['MP3_ROOT'], decoded_filename)) + + if not os.path.isfile(full_path): + return "File not found", 404 + + mime, _ = mimetypes.guess_type(full_path) + mime = mime or 'application/octet-stream' + + response = None + + # Check cache first (using diskcache) + cached = cache.get(filename) + if cached: + cached_file_bytes, mime = cached + cached_file = io.BytesIO(cached_file_bytes) + response = send_file(cached_file, mimetype=mime) + else: + if mime and mime.startswith('image/'): + # Image processing + try: + with Image.open(full_path) as img: + img.thumbnail((1200, 1200)) + img_bytes = io.BytesIO() + img.save(img_bytes, format='PNG', quality=85) + img_bytes = img_bytes.getvalue() + cache.set(filename, (img_bytes, mime)) + response = send_file(io.BytesIO(img_bytes), mimetype=mime) + except Exception as e: + app.logger.error(f"Image processing failed: {e}") + abort(500) + else: + # Other file types + response = send_file(full_path, mimetype=mime) + + # Set Cache-Control header (browser caching for 1 day) + response.headers['Cache-Control'] = 'public, max-age=86400' # 1 day + return response + +@app.route("/transcript/") +@require_secret +def get_transcript(filename): + fs_filename = filename.replace('/', os.sep) + full_path = os.path.join(app.config['MP3_ROOT'], fs_filename) + + if not os.path.isfile(full_path): + return "Transcription not found", 404 + + with open(full_path, 'r', encoding='utf-8') as f: + content = f.read() + return content, 200, {'Content-Type': 'text/markdown; charset=utf-8'} + +# Catch-all route to serve the single-page application template. +@app.route('/', defaults={'path': ''}) +@app.route('/') +@require_secret +def index(path): + # The SPA template (browse.html) will read the current URL and load the correct directory. + return render_template("browse.html") + +if __name__ == "__main__": + app.run(debug=True, host='0.0.0.0') diff --git a/apply_correction.py b/apply_correction.py new file mode 100644 index 0000000..3efeae9 --- /dev/null +++ b/apply_correction.py @@ -0,0 +1,48 @@ +import os +import sys +import re +import json + +def apply_error_correction(text): + # Load the JSON file that contains your translations + with open('error_correction.json', 'r', encoding='utf-8') as file: + correction_dict = json.load(file) + + # Combine keys into a single regex pattern + pattern = r'\b(' + '|'.join(re.escape(key) for key in correction_dict.keys()) + r')\b' + + def replacement_func(match): + key = match.group(0) + return correction_dict.get(key, key) + + # re.subn returns a tuple (new_string, number_of_subs_made) + corrected_text, count = re.subn(pattern, replacement_func, text) + return corrected_text, count + +def process_folder(root_folder): + """ + Walk through root_folder and process .md files. + """ + for dirpath, _, filenames in os.walk(root_folder): + for filename in filenames: + if filename.lower().endswith(".md"): + file_path = os.path.join(dirpath, filename) + + with open(file_path, 'r', encoding='utf-8') as file: + text = file.read() + + corrected_text, error_count = apply_error_correction(text) + + # Only write to file if at least one error was corrected. + if error_count > 0: + with open(file_path, 'w', encoding='utf-8') as file: + file.write(corrected_text) + print(f"{file_path} - {error_count} errors corrected.") + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python apply_correction.py ") + sys.exit(1) + + root_folder = sys.argv[1] + process_folder(root_folder) diff --git a/check_ssh_tunnel.sh b/check_ssh_tunnel.sh new file mode 100644 index 0000000..bf2cc7c --- /dev/null +++ b/check_ssh_tunnel.sh @@ -0,0 +1,19 @@ +# SSH Tunnel Check Script (batch script for cron) +# Add to cron: */5 * * * * /path/to/check_ssh_tunnel.sh + +# --- Script to ensure SSH Tunnel is active --- + +#!/bin/bash + +SSH_USER="your_ssh_user" +SSH_SERVER="ssh-server-address" +LOCAL_PORT="2049" +REMOTE_NFS_SERVER="nfs-server-address" + +# Check if tunnel is active +if ! nc -z localhost $LOCAL_PORT; then + echo "SSH Tunnel is down! Reconnecting..." + ssh -f -N -L ${LOCAL_PORT}:$SSH_SERVER:2049 ${SSH_USER}@${SSH_SERVER} +else + echo "SSH Tunnel is active." +fi \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a082b75 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + flask-app: + image: python:3.11-slim + container_name: flask-app + restart: always + working_dir: /app + volumes: + - ./:/app + - filecache:/app/filecache + - templates:/app/templates + - mp3_root:/mp3_root + environment: + - FLASK_APP=app.py + - FLASK_RUN_HOST=0.0.0.0 + - MP3_ROOT=/mp3_root + ports: + - "5000:5000" + command: > + sh -c "pip install flask pillow diskcache && flask run" + +volumes: + filecache: + templates: + mp3_root: + driver: local + driver_opts: + type: nfs + o: addr=127.0.0.1,rw,nolock,soft + device: ":/path/to/your/nfs/export" diff --git a/error_correction.json b/error_correction.json new file mode 100644 index 0000000..1bed3ba --- /dev/null +++ b/error_correction.json @@ -0,0 +1,29 @@ +{ + "Ausgnade": "aus Gnade", + "Blaschkister": "Blasorchester", + "Der Absalmi": "Der Psalmist", + "Ere Gottes": "Ehre Gottes", + "Ernte Dankfest": "Erntedankfest", + "Galata": "Galater", + "Gottesgenade": "Gottes Gnade", + "Müge Gott": "Möge Gott", + "Philippa": "Philipper", + "Preis zum Herrn": "Preis den Herrn", + "Preistem Herren": "Preis den Herrn", + "Preistime Herren": "Preis den Herrn", + "Preissen, Herren": "Preis den Herrn", + "Preiß, dem Herren": "Preis den Herrn", + "segenden Gebet": "Segensgebet", + "unbarem Herzigkeit": "und Barmherzigkeit", + "Zacchaeus": "Zachäus", + "Kolossus": "Kolosser", + "Thessalonika": "Thessalonicher", + "Hausgenade": "aus Gnade", + "Herr Schwestern": "Geschwister", + "Der Schwestern": "Geschwister", + "Brünnhilde Schwester": "Brüder und Schwester", + "Buchmose": "Buch Mose", + "Caleb": "Kaleb", + "Beethaus": "Bethaus" + +} \ No newline at end of file diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..fdee292 --- /dev/null +++ b/static/app.js @@ -0,0 +1,182 @@ +// Helper function: decode each segment then re-encode to avoid double encoding. +function encodeSubpath(subpath) { + if (!subpath) return ''; + return subpath + .split('/') + .map(segment => encodeURIComponent(decodeURIComponent(segment))) + .join('/'); +} + +// Global variable for gallery images (updated from current folder) +let currentGalleryImages = []; + +// Render breadcrumbs, directories (grid view when appropriate), and files. +function renderContent(data) { + let breadcrumbHTML = ''; + data.breadcrumbs.forEach((crumb, index) => { + breadcrumbHTML += `${crumb.name}`; + if (index < data.breadcrumbs.length - 1) { + breadcrumbHTML += `>`; + } + }); + document.getElementById('breadcrumbs').innerHTML = breadcrumbHTML; + + let contentHTML = ''; + + // Render directories. + if (data.directories.length > 0) { + contentHTML += '