initial commit
This commit is contained in:
commit
a8813e57da
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/venv
|
||||||
237
app.py
Normal file
237
app.py
Normal file
@ -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/<string:size>.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/<path:subpath>')
|
||||||
|
@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/<path:filename>")
|
||||||
|
@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/<path:filename>")
|
||||||
|
@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('/<path:path>')
|
||||||
|
@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')
|
||||||
48
apply_correction.py
Normal file
48
apply_correction.py
Normal file
@ -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 <root_folder>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
root_folder = sys.argv[1]
|
||||||
|
process_folder(root_folder)
|
||||||
19
check_ssh_tunnel.sh
Normal file
19
check_ssh_tunnel.sh
Normal file
@ -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
|
||||||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
@ -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"
|
||||||
29
error_correction.json
Normal file
29
error_correction.json
Normal file
@ -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"
|
||||||
|
|
||||||
|
}
|
||||||
182
static/app.js
Normal file
182
static/app.js
Normal file
@ -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 += `<a href="#" class="breadcrumb-link" data-path="${crumb.path}">${crumb.name}</a>`;
|
||||||
|
if (index < data.breadcrumbs.length - 1) {
|
||||||
|
breadcrumbHTML += `<span>></span>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('breadcrumbs').innerHTML = breadcrumbHTML;
|
||||||
|
|
||||||
|
let contentHTML = '';
|
||||||
|
|
||||||
|
// Render directories.
|
||||||
|
if (data.directories.length > 0) {
|
||||||
|
contentHTML += '<ul>';
|
||||||
|
// Check if every directory name is short (≤15 characters)
|
||||||
|
const areAllShort = data.directories.every(dir => dir.name.length <= 15);
|
||||||
|
if (areAllShort) {
|
||||||
|
contentHTML += '<div class="directories-grid">';
|
||||||
|
data.directories.forEach(dir => {
|
||||||
|
contentHTML += `<div class="directory-item">📁 <a href="#" class="directory-link" data-path="${dir.path}">${dir.name}</a></div>`;
|
||||||
|
});
|
||||||
|
contentHTML += '</div>';
|
||||||
|
} else {
|
||||||
|
contentHTML += '<ul>';
|
||||||
|
data.directories.forEach(dir => {
|
||||||
|
contentHTML += `<li class="directory-item">📁 <a href="#" class="directory-link" data-path="${dir.path}">${dir.name}</a></li>`;
|
||||||
|
});
|
||||||
|
contentHTML += '</ul>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render files.
|
||||||
|
if (data.files.length > 0) {
|
||||||
|
contentHTML += '<ul>';
|
||||||
|
data.files.forEach(file => {
|
||||||
|
let symbol = '';
|
||||||
|
if (file.file_type === 'music') {
|
||||||
|
symbol = '🎵';
|
||||||
|
} else if (file.file_type === 'image') {
|
||||||
|
symbol = '🖼️';
|
||||||
|
}
|
||||||
|
contentHTML += `<li class="file-item">
|
||||||
|
<a href="#" class="play-file" data-url="${file.path}" data-file-type="${file.file_type}">${symbol} ${file.name}</a>`;
|
||||||
|
if (file.has_transcript) {
|
||||||
|
contentHTML += `<a href="#" class="show-transcript" data-url="${file.transcript_url}" title="Show Transcript">📄</a>`;
|
||||||
|
}
|
||||||
|
contentHTML += `</li>`;
|
||||||
|
});
|
||||||
|
contentHTML += '</ul>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<p>Error loading directory.</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = '<p>Error loading transcription.</p>';
|
||||||
|
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);
|
||||||
|
});
|
||||||
84
static/gallery.css
Normal file
84
static/gallery.css
Normal file
@ -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; }
|
||||||
204
static/gallery.js
Normal file
204
static/gallery.js
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
BIN
static/logo.png
Normal file
BIN
static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
21
static/manifest.json
Normal file
21
static/manifest.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
210
static/styles.css
Normal file
210
static/styles.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
static/sw.js
Normal file
26
static/sw.js
Normal file
@ -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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
74
templates/browse.html
Normal file
74
templates/browse.html
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Gottesdienste</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" href="/static/icons/logo-192x192.png" type="image/png" sizes="192x192">
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Web App Manifest -->
|
||||||
|
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
||||||
|
|
||||||
|
<!-- Android Theme Color -->
|
||||||
|
<meta name="theme-color" content="#3367D6">
|
||||||
|
|
||||||
|
<!-- Apple-specific tags -->
|
||||||
|
<link rel="touch-icon" href="{{ url_for('static', filename='icons/icon-192x192.png') }}">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="mobile-web-app-title" content="Gottesdienste">
|
||||||
|
|
||||||
|
<!-- Your CSS -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='gallery.css') }}">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h2>Gottesdienste Speyer und Schwegenheim</h2>
|
||||||
|
<div id="breadcrumbs" class="breadcrumb"></div>
|
||||||
|
<div id="content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global Audio Player in Footer -->
|
||||||
|
<footer style="display: none;">
|
||||||
|
<div class="audio-player-container">
|
||||||
|
<audio id="globalAudio" controls>
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
<div id="nowPlayingInfo" class="now-playing-info"></div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Transcript Modal -->
|
||||||
|
<div id="transcriptModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<div id="transcriptContent"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gallery Modal for Images -->
|
||||||
|
<div id="gallery-modal" style="display: none;">
|
||||||
|
<div id="gallery-loader"></div>
|
||||||
|
<img id="gallery-modal-content" src="" alt="Gallery Image" />
|
||||||
|
<button class="gallery-nav gallery-prev">‹</button>
|
||||||
|
<button class="gallery-nav gallery-next">›</button>
|
||||||
|
<button id="gallery-close">x</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load main app JS first, then gallery JS -->
|
||||||
|
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='gallery.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('{{ url_for('static', filename='sw.js') }}')
|
||||||
|
.then(reg => console.log('Service worker registered.', reg))
|
||||||
|
.catch(err => console.error('Service worker not registered.', err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
templates/error.html
Normal file
16
templates/error.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Keine Berechtigung</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h2>Keine Berechtigung</h2>
|
||||||
|
<div id="content">Bitte mit Link aus Telegram-Gruppe erneut anklicken.</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
245
transcribe_all.py
Normal file
245
transcribe_all.py
Normal file
@ -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 <root_folder>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
root_folder = sys.argv[1]
|
||||||
|
|
||||||
|
process_folder(root_folder)
|
||||||
Loading…
x
Reference in New Issue
Block a user