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