initial commit

This commit is contained in:
lelo 2025-03-16 19:54:47 +01:00
commit a8813e57da
16 changed files with 1425 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/venv

237
app.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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>&gt;</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">&#128196;</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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

21
static/manifest.json Normal file
View 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
View 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
View 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
View 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">&times;</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
View 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
View 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)