commit a8813e57da92aba86729050303bc6271b082295b Author: lelo Date: Sun Mar 16 19:54:47 2025 +0100 initial commit 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 += '
    '; + // Check if every directory name is short (≤15 characters) + const areAllShort = data.directories.every(dir => dir.name.length <= 15); + if (areAllShort) { + contentHTML += '
    '; + data.directories.forEach(dir => { + contentHTML += ``; + }); + contentHTML += '
    '; + } else { + contentHTML += '
      '; + data.directories.forEach(dir => { + contentHTML += `
    • 📁 ${dir.name}
    • `; + }); + contentHTML += '
    '; + } + } + + // Render files. + if (data.files.length > 0) { + contentHTML += '
      '; + data.files.forEach(file => { + let symbol = ''; + if (file.file_type === 'music') { + symbol = '🎵'; + } else if (file.file_type === 'image') { + symbol = '🖼️'; + } + contentHTML += `
    • + ${symbol} ${file.name}`; + if (file.has_transcript) { + contentHTML += `📄`; + } + contentHTML += `
    • `; + }); + contentHTML += '
    '; + } + + document.getElementById('content').innerHTML = contentHTML; + + // Update global variable for gallery images (only image files). + currentGalleryImages = data.files + .filter(f => f.file_type === 'image') + .map(f => f.path); + + attachEventListeners(); // Reattach event listeners for newly rendered elements. +} + +// Fetch directory data from the API. +function loadDirectory(subpath) { + const encodedPath = encodeSubpath(subpath); + const apiUrl = '/api/path/' + encodedPath; + fetch(apiUrl) + .then(response => response.json()) + .then(data => { + renderContent(data); + }) + .catch(error => { + console.error('Error loading directory:', error); + document.getElementById('content').innerHTML = '

    Error loading directory.

    '; + }); +} + +// Attach event listeners for directory, breadcrumb, file, and transcript links. +function attachEventListeners() { + // Directory link clicks. + document.querySelectorAll('.directory-link').forEach(link => { + link.addEventListener('click', function (event) { + event.preventDefault(); + const newPath = this.getAttribute('data-path'); + loadDirectory(newPath); + history.pushState({ subpath: newPath }, '', newPath ? '/path/' + newPath : '/'); + }); + }); + + // Breadcrumb link clicks. + document.querySelectorAll('.breadcrumb-link').forEach(link => { + link.addEventListener('click', function (event) { + event.preventDefault(); + const newPath = this.getAttribute('data-path'); + loadDirectory(newPath); + history.pushState({ subpath: newPath }, '', newPath ? '/path/' + newPath : '/'); + }); + }); + + // File play link clicks. + document.querySelectorAll('.play-file').forEach(link => { + link.addEventListener('click', function (event) { + event.preventDefault(); + const fileType = this.getAttribute('data-file-type'); + const relUrl = this.getAttribute('data-url'); + if (fileType === 'music') { + const mediaUrl = '/media/' + relUrl; + const audioPlayer = document.getElementById('globalAudio'); + audioPlayer.src = mediaUrl; + audioPlayer.load(); + audioPlayer.play(); + document.getElementById('nowPlayingInfo').textContent = relUrl; + document.querySelector('footer').style.display = 'flex'; + } else if (fileType === 'image') { + // Open gallery modal via gallery.js function. + openGalleryModal(relUrl); + } + }); + }); + + // Transcript icon clicks. + document.querySelectorAll('.show-transcript').forEach(link => { + link.addEventListener('click', function (event) { + event.preventDefault(); + const url = this.getAttribute('data-url'); + fetch(url) + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.text(); + }) + .then(data => { + document.getElementById('transcriptContent').innerHTML = marked.parse(data); + document.getElementById('transcriptModal').style.display = 'block'; + }) + .catch(error => { + document.getElementById('transcriptContent').innerHTML = '

    Error loading transcription.

    '; + document.getElementById('transcriptModal').style.display = 'block'; + }); + }); + }); +} + +// Modal close logic for transcript modal. +document.addEventListener('DOMContentLoaded', function () { + const closeBtn = document.querySelector('#transcriptModal .close'); + closeBtn.addEventListener('click', function () { + document.getElementById('transcriptModal').style.display = 'none'; + }); + window.addEventListener('click', function (event) { + if (event.target == document.getElementById('transcriptModal')) { + document.getElementById('transcriptModal').style.display = 'none'; + } + }); + + // Load initial directory based on URL. + let initialSubpath = ''; + if (window.location.pathname.indexOf('/path/') === 0) { + initialSubpath = window.location.pathname.substring(6); // remove "/path/" + } + loadDirectory(initialSubpath); +}); + +// Handle back/forward navigation. +window.addEventListener('popstate', function (event) { + const subpath = event.state ? event.state.subpath : ''; + loadDirectory(subpath); +}); diff --git a/static/gallery.css b/static/gallery.css new file mode 100644 index 0000000..71aba23 --- /dev/null +++ b/static/gallery.css @@ -0,0 +1,84 @@ +#gallery-modal, +.gallery-nav, +.gallery-nav:focus, +.gallery-nav:active, +#gallery-modal-content, +#gallery-close { + -webkit-tap-highlight-color: transparent; /* Disable blue highlight on Android/iOS/Chrome/Safari */ + outline: none; /* Disable outline on click/focus */ +} + +/* Gallery Modal Styles */ +#gallery-modal { + display: none; /* Hidden by default */ + position: fixed; + z-index: 3000; + top: 0; + left: 0; + width: 100%; + height: 100vh; + background-color: rgba(0,0,0,1); + justify-content: center; + align-items: center; + cursor: pointer; + } + + #gallery-modal-content{ + max-width: 95%; + max-height: 95vh; + object-fit: contain; + margin: auto; + display: block; + cursor: pointer; + } + + /* Close Button positioning */ + #gallery-close { + position: fixed; + top: 20px; + right: 20px; + font-size: 24px; + color: #fff; + cursor: pointer; + z-index: 10; + background: transparent; + border: none; + padding: 0; + margin: 0; + } + + + /* Loader for gallery */ + #gallery-loader { + display: none; /* Hidden by default */ + border: 8px solid #f3f3f3; /* Light gray */ + border-top: 8px solid #525252; /* Blue */ + border-radius: 50%; + width: 10px; + height: 10px; + padding-left: 50%; + padding-top: 50%; + animation: spin 1.5s linear infinite; + } + + /* Keyframes for spinner animation */ + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + .gallery-nav { + position: fixed; + top: 50%; + transform: translateY(-50%); + font-size: 3rem; + color: white; + background: rgba(0,0,0,0.1); + border: none; + padding: 1rem; + cursor: pointer; + z-index: 10; + } + + .gallery-prev { left: 20px; } + .gallery-next { right: 20px; } \ No newline at end of file diff --git a/static/gallery.js b/static/gallery.js new file mode 100644 index 0000000..3ea3200 --- /dev/null +++ b/static/gallery.js @@ -0,0 +1,204 @@ +// Assume that currentGalleryImages is already declared in app.js. +// Only declare currentGalleryIndex if it isn't defined already. +if (typeof currentGalleryIndex === "undefined") { + var currentGalleryIndex = -1; + } + + function openGalleryModal(relUrl) { + document.body.style.overflow = 'hidden'; // Disable background scrolling. + currentGalleryIndex = currentGalleryImages.indexOf(relUrl); + showGalleryImage(relUrl); + document.getElementById('gallery-modal').style.display = 'flex'; + } + + function showGalleryImage(relUrl) { + const fullUrl = '/media/' + relUrl; + const modal = document.getElementById('gallery-modal'); + const currentImage = document.getElementById('gallery-modal-content'); + const loader = document.getElementById('gallery-loader'); + const closeBtn = document.getElementById('gallery-close'); + + // Set a timeout for showing the loader after 500ms. + let loaderTimeout = setTimeout(() => { + loader.style.display = 'block'; + }, 500); + + // Preload the new image. + const tempImg = new Image(); + tempImg.src = fullUrl; + + tempImg.onload = function () { + clearTimeout(loaderTimeout); + loader.style.display = 'none'; + + // Select all current images in the gallery modal + const existingImages = document.querySelectorAll('#gallery-modal img'); + + // Create a new image element for the transition + const newImage = document.createElement('img'); + newImage.src = fullUrl; + newImage.style.position = 'absolute'; + newImage.style.top = '50%'; // Center vertically + newImage.style.left = '50%'; // Center horizontally + newImage.style.transform = 'translate(-50%, -50%)'; // Ensure centering + newImage.style.maxWidth = '95vw'; // Prevent overflow on large screens + newImage.style.maxHeight = '95vh'; // Prevent overflow on tall screens + newImage.style.transition = 'opacity 0.5s ease-in-out'; + newImage.style.opacity = 0; // Start fully transparent + newImage.id = 'gallery-modal-content'; + + // Append new image on top of existing ones + modal.appendChild(newImage); + + // Fade out all old images and fade in the new one + setTimeout(() => { + existingImages.forEach(img => { + img.style.opacity = 0; // Apply fade-out + }); + newImage.style.opacity = 1; // Fade in the new image + }, 10); + + // Wait for fade-out to finish, then remove all old images + setTimeout(() => { + existingImages.forEach(img => { + if (img.parentNode) { + img.parentNode.removeChild(img); // Remove old images + } + }); + }, 510); // 500ms for fade-out + small buffer + + // Preload the next image + preloadNextImage(); + }; + + + tempImg.onerror = function() { + clearTimeout(loaderTimeout); + loader.style.display = 'none'; + console.error('Error loading image:', fullUrl); + }; + } + + function preloadNextImage() { + if (currentGalleryIndex < currentGalleryImages.length - 1) { + const nextImage = new Image(); + nextImage.src = '/media/' + currentGalleryImages[currentGalleryIndex + 1]; + } + } + + function closeGalleryModal() { + document.getElementById('gallery-modal').style.display = 'none'; + document.body.style.overflow = ''; // Restore scrolling. + } + + function handleGalleryImageClick(e) { + if (e.target.classList.contains('gallery-next') || e.target.classList.contains('gallery-prev')) { + return; // Prevent conflict + } + + const imgRect = this.getBoundingClientRect(); + if (e.clientX < imgRect.left + imgRect.width / 2) { + if (currentGalleryIndex > 0) { + currentGalleryIndex--; + showGalleryImage(currentGalleryImages[currentGalleryIndex]); + } + } else { + if (currentGalleryIndex < currentGalleryImages.length - 1) { + currentGalleryIndex++; + showGalleryImage(currentGalleryImages[currentGalleryIndex]); + } + } +} + + + + + function initGallerySwipe() { + let touchStartX = 0; + let touchEndX = 0; + const galleryModal = document.getElementById('gallery-modal'); + + galleryModal.addEventListener('touchstart', function(e) { + touchStartX = e.changedTouches[0].screenX; + }, false); + + galleryModal.addEventListener('touchend', function(e) { + touchEndX = e.changedTouches[0].screenX; + handleSwipe(); + }, false); + + function handleSwipe() { + const deltaX = touchEndX - touchStartX; + if (Math.abs(deltaX) > 50) { // Swipe threshold. + if (deltaX > 0) { // Swipe right: previous image. + if (currentGalleryIndex > 0) { + currentGalleryIndex--; + showGalleryImage(currentGalleryImages[currentGalleryIndex]); + } + } else { // Swipe left: next image. + if (currentGalleryIndex < currentGalleryImages.length - 1) { + currentGalleryIndex++; + showGalleryImage(currentGalleryImages[currentGalleryIndex]); + } + } + } + } + } + + + + + function initGalleryControls() { + const nextButton = document.querySelector('.gallery-next'); + const prevButton = document.querySelector('.gallery-prev'); + const closeButton = document.getElementById('gallery-close'); + + if (nextButton) { + nextButton.addEventListener('click', function () { + if (currentGalleryIndex < currentGalleryImages.length - 1) { + currentGalleryIndex++; + showGalleryImage(currentGalleryImages[currentGalleryIndex]); + } + }); + } + + if (prevButton) { + prevButton.addEventListener('click', function () { + if (currentGalleryIndex > 0) { + currentGalleryIndex--; + showGalleryImage(currentGalleryImages[currentGalleryIndex]); + } + }); + } + + if (closeButton) { + closeButton.addEventListener('click', closeGalleryModal); + } +} + + + function initGallery() { + const galleryImage = document.getElementById('gallery-modal-content'); + if (galleryImage) { + galleryImage.addEventListener('click', handleGalleryImageClick); + } + initGallerySwipe(); + initGalleryControls(); + + // Close modal when clicking outside the image. + const galleryModal = document.getElementById('gallery-modal'); + galleryModal.addEventListener('click', function(e) { + if (e.target === galleryModal) { + closeGalleryModal(); + } + }); + + } + + // Initialize the gallery once the DOM content is loaded. + if (document.readyState === "loading") { + document.addEventListener('DOMContentLoaded', initGallery); + } else { + initGallery(); + } + \ No newline at end of file diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000..d2fcd45 Binary files /dev/null and b/static/logo.png differ diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..b540ded --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "Gottesdienste Speyer und Schwegenheim", + "short_name": "Gottesdienste", + "start_url": "/", + "display": "fullscreen", + "background_color": "#ffffff", + "theme_color": "#3367D6", + "icons": [ + { + "src": "/static/icons/logo-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/icons/logo-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] + } + \ No newline at end of file diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..afd8561 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,210 @@ +/* Global Styles */ +body { + font-family: 'Helvetica Neue', Arial, sans-serif; + background-color: #f4f7f9; + margin: 0; + padding: 0; + color: #333; +} +.container { + max-width: 900px; + margin: 0 auto; + padding: 20px; + padding-bottom: 150px; +} +h1 { + text-align: center; + margin-bottom: 10px; +} + +/* Breadcrumb Styles */ +.breadcrumb { + margin-bottom: 20px; + font-size: 16px; +} +.breadcrumb a { + text-decoration: none; + color: #3498db; + margin-right: 5px; +} +.breadcrumb span { + color: #7f8c8d; + margin-right: 5px; +} + +/* List Styles */ +ul { + list-style-type: none; + padding: 0; +} +li { + margin: 10px 0; + padding: 10px; + background-color: #fff; + border-radius: 5px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +/* Directory Items (in a list) */ +.directory-item { + /* Use flex for list items if needed */ +} + +/* Grid Layout for Directories */ +.directories-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; +} +.directories-grid .directory-item { + background-color: #fff; + padding: 10px; + border-radius: 5px; + text-align: center; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +/* File Item Styles (for both music and image files) */ +.file-item { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + margin: 10px 0; + padding: 10px; + background-color: #fff; + border-radius: 5px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +/* Link Styles */ +a.directory-link, +a.play-file { + color: #2c3e50; + font-weight: bold; + text-decoration: none; + word-break: break-all; +} +a.directory-link:hover, +a.play-file:hover { + color: #2980b9; +} +a.show-transcript { + text-decoration: none; + color: #e67e22; + font-size: 20px; + margin-left: 10px; +} +a.show-transcript:hover { + color: #d35400; +} + +/* Footer Player Styles */ +footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background-color: #34495e; + color: #fff; + padding: 10px; + display: flex; + align-items: center; + justify-content: center; +} +.audio-player-container { + background-color: #fff; + border-radius: 8px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); + padding: 10px; + width: 100%; + max-width: 800px; +} +footer audio { + width: 100%; + border: none; + border-radius: 8px; + outline: none; + background: transparent; +} + + +/* Now Playing Info */ +.now-playing-info { + margin-top: 5px; + font-size: 14px; + color: #2c3e50; + text-align: center; +} + +/* Modal Styles */ +#transcriptModal { + display: none; + position: fixed; + z-index: 2000; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0, 0, 0, 0.7); +} +#transcriptModal .modal-content { + background-color: #fff; + margin: 5% auto; + padding: 20px; + border-radius: 5px; + width: 90%; + max-width: 800px; + position: relative; + max-height: 80vh; + overflow-y: auto; +} +#transcriptModal .close { + position: absolute; + right: 15px; + top: 10px; + font-size: 28px; + font-weight: bold; + cursor: pointer; +} + +/* Basic Markdown Styles */ +#transcriptContent h1, +#transcriptContent h2, +#transcriptContent h3 { + border-bottom: 1px solid #eee; + padding-bottom: 5px; +} +#transcriptContent p { + line-height: 1.6; +} +#transcriptContent pre { + background-color: #f6f8fa; + padding: 10px; + overflow-x: auto; +} + +/* Responsive Adjustments */ +@media (max-width: 600px) { + .container { + padding: 10px; + padding-bottom: 150px; + } + h1 { + font-size: 24px; + } + .breadcrumb { + font-size: 18px; + } + .mp3-item { + grid-template-columns: 1fr auto; + } + a.show-transcript { + margin-left: 0; + margin-top: 5px; + } + footer { + flex-direction: column; + padding: 15px 5px; + } +} \ No newline at end of file diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..cbdca7f --- /dev/null +++ b/static/sw.js @@ -0,0 +1,26 @@ +const cacheName = 'gottesdienste-v1'; +const assets = [ + '/', + '/static/styles.css', + '/static/gallery.css', + '/static/app.js', + '/static/gallery.js', + '/static/icons/logo-192x192.png', + '/static/icons/logo-512x512.png' +]; + +self.addEventListener('install', e => { + e.waitUntil( + caches.open(cacheName).then(cache => { + return cache.addAll(assets); + }) + ); +}); + +self.addEventListener('fetch', e => { + e.respondWith( + caches.match(e.request).then(response => { + return response || fetch(e.request); + }) + ); +}); \ No newline at end of file diff --git a/templates/browse.html b/templates/browse.html new file mode 100644 index 0000000..a2301ea --- /dev/null +++ b/templates/browse.html @@ -0,0 +1,74 @@ + + + + + Gottesdienste + + + + + + + + + + + + + + + + + + + + + + +
    +

    Gottesdienste Speyer und Schwegenheim

    + +
    +
    + + +
    +
    + +
    +
    +
    + + +
    + +
    + + + + + + + + + + diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..1f67395 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,16 @@ + + + + + Keine Berechtigung + + + + + +
    +

    Keine Berechtigung

    +
    Bitte mit Link aus Telegram-Gruppe erneut anklicken.
    +
    + + diff --git a/transcribe_all.py b/transcribe_all.py new file mode 100644 index 0000000..8d5d08f --- /dev/null +++ b/transcribe_all.py @@ -0,0 +1,245 @@ +import os +import sys +import time +import whisper +import concurrent.futures +import json +import re + +# model_name = "large-v3" +model_name = "medium" + +# start time for transcription statistics +start_time = 0 +total_audio_length = 0 + +def format_timestamp(seconds): + """Format seconds into HH:MM:SS.""" + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + if hours == 0: + return f"{minutes:02}:{secs:02}" + else: + return f"{hours:02}:{minutes:02}:{secs:02}" + +def format_status_path(path): + """Return a string with only the immediate parent folder and the filename.""" + filename = os.path.basename(path) + parent = os.path.basename(os.path.dirname(path)) + if parent: + return os.path.join(parent, filename) + return filename + +def remove_lines_with_words(transcript): + """Removes the last line from the transcript if any banned word is found in it.""" + # Define banned words + banned_words = ["copyright", "ard", "zdf", "wdr"] + + # Split transcript into lines + lines = transcript.rstrip().splitlines() + if not lines: + return transcript # Return unchanged if transcript is empty + + # Check the last line + last_line = lines[-1] + if any(banned_word.lower() in last_line.lower() for banned_word in banned_words): + # Remove the last line if any banned word is present + lines = lines[:-1] + + return "\n".join(lines) + +def apply_error_correction(text): + # Load the JSON file that contains your error_correction + 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) + + return re.sub(pattern, replacement_func, text) + +def print_speed(current_length): + global start_time + global total_audio_length + # Calculate transcription time statistics + elapsed_time = time.time() - start_time + + total_audio_length = total_audio_length + current_length + + # Calculate transcription speed: minutes of audio transcribed per hour of processing. + # Formula: (audio duration in minutes) / (elapsed time in hours) + if elapsed_time > 0: + trans_speed = (total_audio_length / 60) / (elapsed_time / 3600) + else: + trans_speed = 0 + + print(f" | Speed: {int(trans_speed)} minutes per hour | ", end='', flush=True) + +def write_markdown(file_path, result, postfix=None): + file_dir = os.path.dirname(file_path) + txt_folder = os.path.join(file_dir, "Transkription") + os.makedirs(txt_folder, exist_ok=True) + base_name = os.path.splitext(os.path.basename(file_path))[0] + if postfix != None: + base_name = f"{base_name}_{postfix}" + output_md = os.path.join(txt_folder, base_name + ".md") + + # Prepare the markdown content. + folder_name = os.path.basename(file_dir) + md_lines = [ + f"### {folder_name}", + f"#### {os.path.basename(file_path)}", + "---", + "" + ] + + previous_text = "" + for segment in result["segments"]: + start = format_timestamp(segment["start"]) + text = segment["text"].strip() + if previous_text != text: # suppress repeating lines + md_lines.append(f"`{start}` {text}") + previous_text = text + + transcript_md = "\n".join(md_lines) + + transcript_md = apply_error_correction(transcript_md) + + transcript_md = remove_lines_with_words(transcript_md) + + with open(output_md, "w", encoding="utf-8") as f: + f.write(transcript_md) + + print_speed(result["segments"][-1]["end"]) + print(f"... done !") + +def transcribe_file(model, audio_input, language): + initial_prompt = ( + "Dieses Audio ist eine Aufnahme eines christlichen Gottesdienstes, " + "das biblische Zitate, religiöse Begriffe und typische Gottesdienst-Phrasen enthält. " + "Achte darauf auf folgende Begriffe, die häufig falsch transkribiert wurden, korrekt wiederzugeben: " + "Stiftshütte, Bundeslade, Heiligtum, Offenbarung, Evangelium, Buße, Golgatha, " + "Apostelgeschichte, Auferstehung, Wiedergeburt. " + "Das Wort 'Bethaus' wird häufig als synonym für 'Gebetshaus' verwendet. " + "Das Wort 'Abendmahl' ist wichtig und sollte zuverlässig erkannt werden. " + "Ebenso müssen biblische Namen und Persönlichkeiten exakt transkribiert werden. " + "Zahlenangaben, beispielsweise Psalmnummern oder Bibelverse, sollen numerisch dargestellt werden." + ) + result = model.transcribe(audio_input, initial_prompt=initial_prompt, language=language) + return result + +def detect_language(model, audio): + print(" Language detected: ", end='', flush=True) + audio = whisper.pad_or_trim(audio) + mel = whisper.log_mel_spectrogram(audio, n_mels=model.dims.n_mels).to(model.device) + _, probs = model.detect_language(mel) + lang_code = max(probs, key=probs.get) + print(f"{lang_code}. ", end='', flush=True) + return lang_code + +def process_file(file_path, model, audio_input): + file_name = os.path.basename(file_path) + + # default values + postfix = None + language = detect_language(model, audio_input) + + if language == 'ru' and 'predigt' in file_name.lower() or language == 'de' and 'russisch' in file_name.lower(): # make two files + # first file + language="ru" + postfix = "ru" + print(f"Transcribing {format_status_path(file_path)} ", end='', flush=True) + markdown = transcribe_file(model, audio_input, language) + write_markdown(file_path, markdown, postfix) + # second file + language="de" + postfix = "de" + elif language == 'en': # songs mostly detect as english + language="de" + elif language == 'de' or language == 'ru': # keep as detected + pass + else: # not german not english and not russian. --> russina + language="ru" + + print(f"Transcribing {format_status_path(file_path)} ", end='', flush=True) + markdown = transcribe_file(model, audio_input, language) + write_markdown(file_path, markdown, postfix) + +def process_folder(root_folder): + """ + Walk through root_folder and process .mp3 files, applying skip rules. + Only files that need to be transcribed (i.e. transcription does not already exist) + will have their audio pre-loaded concurrently. + """ + global start_time + keywords = ["musik", "chor", "lied", "gesang", "orchester", "orhester", "melodi", "sot"] + print("Create file list...") + + valid_files = [] + checked_files = 0 + # Walk the folder and build a list of files to transcribe. + for dirpath, _, filenames in os.walk(root_folder): + for filename in filenames: + if filename.lower().endswith(".mp3"): + checked_files = checked_files + 1 + filename_lower = filename.lower() + file_path = os.path.join(dirpath, filename) + # Skip files with skip keywords. + if "vorwort" not in filename_lower and any(keyword in filename_lower for keyword in keywords): + continue + + # Compute expected output markdown path. + txt_folder = os.path.join(dirpath, "Transkription") + base_name = os.path.splitext(os.path.basename(file_path))[0] + output_md = os.path.join(txt_folder, base_name + ".md") + output_md_de = os.path.join(txt_folder, base_name + "_de.md") + output_md_ru = os.path.join(txt_folder, base_name + "_ru.md") + # skip files with existing md files + if os.path.exists(output_md) or os.path.exists(output_md_de) or os.path.exists(output_md_ru): + continue + + valid_files.append(file_path) + + if len(valid_files) == 0: + print(f"Checked {checked_files} files. All files are transcribed.") + return + else: + print(f"Checked {checked_files} files. Start to transcribe {len(valid_files)} files.") + + print("Loading Whisper model...") + model = whisper.load_model(model_name) + + # Use a thread pool to pre-load files concurrently. + with concurrent.futures.ThreadPoolExecutor() as executor: + # Pre-load the first file. + print("Initialize preloading process...") + future_audio = executor.submit(whisper.load_audio, valid_files[0]) + # Wait for the first file to be loaded. + preloaded_audio = future_audio.result() + # Record start time for transcription statistics + start_time = time.time() + + for i, file_path in enumerate(valid_files): + preloaded_audio = future_audio.result() + # Start loading the next file concurrently. + if i + 1 < len(valid_files): + future_audio = executor.submit(whisper.load_audio, valid_files[i + 1]) + try: # continue with next file if a file fails + process_file(file_path, model, preloaded_audio) + except Exception as e: + print(f"Error with file {file_path}") + print(e) + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python transcribe_all.py ") + sys.exit(1) + + root_folder = sys.argv[1] + + process_folder(root_folder)