Compare commits

...

5 Commits

7 changed files with 263 additions and 147 deletions

1
.gitignore vendored
View File

@ -16,3 +16,4 @@
/app_config.json /app_config.json
/custom_logo /custom_logo
/static/theme.css /static/theme.css
/transcription_folder.yml

142
app.py
View File

@ -1,6 +1,6 @@
from flask import Flask, render_template, send_file, url_for, jsonify, request, session, send_from_directory, abort from flask import Flask, render_template, send_file, url_for, jsonify, request, session, send_from_directory, make_response, abort
import os import os
from PIL import Image from PIL import Image, ImageOps
import io import io
from functools import wraps from functools import wraps
import mimetypes import mimetypes
@ -100,6 +100,8 @@ def list_directory_contents(directory, subpath):
music_exts = ('.mp3',) music_exts = ('.mp3',)
image_exts = ('.jpg', '.jpeg', '.png', '.gif', '.bmp') image_exts = ('.jpg', '.jpeg', '.png', '.gif', '.bmp')
blocked_filenames = ['Thumbs.db']
try: try:
with os.scandir(directory) as it: with os.scandir(directory) as it:
# Sorting by name if required. # Sorting by name if required.
@ -108,6 +110,10 @@ def list_directory_contents(directory, subpath):
if entry.name.startswith('.'): if entry.name.startswith('.'):
continue continue
# Skip blocked_filenames
if entry.name in blocked_filenames:
continue
if entry.is_dir(follow_symlinks=False): if entry.is_dir(follow_symlinks=False):
if entry.name in ["Transkription", "@eaDir"]: if entry.name in ["Transkription", "@eaDir"]:
continue continue
@ -249,7 +255,6 @@ def serve_file(subpath):
# 2) Prep request info # 2) Prep request info
mime, _ = mimetypes.guess_type(full_path) mime, _ = mimetypes.guess_type(full_path)
mime = mime or 'application/octet-stream' mime = mime or 'application/octet-stream'
is_cache_request = request.headers.get('X-Cache-Request') == 'true' is_cache_request = request.headers.get('X-Cache-Request') == 'true'
ip_address = request.remote_addr ip_address = request.remote_addr
user_agent = request.headers.get('User-Agent') user_agent = request.headers.get('User-Agent')
@ -260,55 +265,102 @@ def serve_file(subpath):
do_log = False do_log = False
# 3) Pick cache # 3) Pick cache
if mime.startswith('audio/'): cache = cache_audio if mime.startswith('audio/'):
elif mime.startswith('image/'): cache = cache_image cache = cache_audio
elif mime.startswith('video/'): cache = cache_video elif mime.startswith('image/'):
else: cache = cache_other cache = cache_image
elif mime.startswith('video/'):
# 4) Ensure cached ondisk file and get its path cache = cache_video
try:
with cache.read(subpath) as reader:
file_path = reader.name
except KeyError:
# cache miss
if mime.startswith('image/'):
# ─── 4a) Image branch: thumbnail & cache the JPEG ───
try:
with Image.open(full_path) as img:
img.thumbnail((1920, 1920))
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
thumb_io = io.BytesIO()
img.save(thumb_io, format='JPEG', quality=85)
thumb_io.seek(0)
# write thumbnail into diskcache as a real file
cache.set(subpath, thumb_io, read=True)
# now re-open from cache to get the on-disk path
with cache.read(subpath) as reader:
file_path = reader.name
except Exception as e:
app.logger.error(f"Image processing failed for {subpath}: {e}")
abort(500)
else: else:
# ─── 4b) Non-image branch: cache original file ─── cache = cache_other
# 4) Image and thumbnail handling first
if mime.startswith('image/'):
small = request.args.get('thumbnail') == 'true'
name, ext = os.path.splitext(subpath)
orig_key = subpath
small_key = f"{name}_small{ext}"
cache_key = small_key if small else orig_key
try:
# Try to read the requested variant
with cache.read(cache_key) as reader:
file_path = reader.name
cached_hit = True
except KeyError:
if small: # do not create when thumbnail requested
response = make_response('', 204)
return response
cached_hit = False
# On miss: generate both full-size and small thumb, then cache
with Image.open(full_path) as orig:
img = ImageOps.exif_transpose(orig)
variants = {
orig_key: (1920, 1920),
small_key: ( 480, 480),
}
for key, size in variants.items():
thumb = img.copy()
thumb.thumbnail(size, Image.LANCZOS)
if thumb.mode in ("RGBA", "P"):
thumb = thumb.convert("RGB")
bio = io.BytesIO()
thumb.save(bio, format='JPEG', quality=85)
bio.seek(0)
cache.set(key, bio, read=True)
# Read back the variant we need
with cache.read(cache_key) as reader:
file_path = reader.name
# Serve the image variant
response = send_file(
file_path,
mimetype=mime,
conditional=True,
as_attachment=(request.args.get('download') == 'true'),
download_name=os.path.basename(orig_key)
)
response.headers['Content-Disposition'] = 'inline'
response.headers['Cache-Control'] = 'public, max-age=86400'
if do_log and not small:
a.log_file_access(
cache_key,
os.path.getsize(file_path),
mime,
ip_address,
user_agent,
session['device_id'],
cached_hit
)
return response
# 5) Non-image branch: ensure original is cached
try:
with cache.read(subpath) as reader:
file_path = reader.name
cached_hit = True
except KeyError:
cached_hit = False
try: try:
# store the real file on diskcache
cache.set(subpath, open(full_path, 'rb'), read=True) cache.set(subpath, open(full_path, 'rb'), read=True)
# read back to get its path
with cache.read(subpath) as reader: with cache.read(subpath) as reader:
file_path = reader.name file_path = reader.name
except Exception as e: except Exception as e:
app.logger.error(f"Failed to cache file {subpath}: {e}") app.logger.error(f"Failed to cache file {subpath}: {e}")
abort(500) abort(500)
# 6) Build response for non-image
filesize = os.path.getsize(file_path) filesize = os.path.getsize(file_path)
cached_hit = True # or False if you want to pass that into your logger
# 5) Figure out download flag and filename # Figure out download flag and filename
ask_download = request.args.get('download') == 'true' ask_download = request.args.get('download') == 'true'
filename = os.path.basename(full_path) filename = os.path.basename(full_path)
# 6) Single send_file call with proper attachment handling # Single send_file call with proper attachment handling
response = send_file( response = send_file(
file_path, file_path,
mimetype=mime, mimetype=mime,
@ -316,21 +368,21 @@ def serve_file(subpath):
as_attachment=ask_download, as_attachment=ask_download,
download_name=filename if ask_download else None download_name=filename if ask_download else None
) )
# Explicitly force inline if not downloading
if not ask_download: if not ask_download:
response.headers['Content-Disposition'] = 'inline' response.headers['Content-Disposition'] = 'inline'
response.headers['Cache-Control'] = 'public, max-age=86400' response.headers['Cache-Control'] = 'public, max-age=86400'
# 7) Logging # 7) Logging
if do_log: if do_log:
a.log_file_access( a.log_file_access(
subpath, filesize, mime, subpath,
ip_address, user_agent, filesize,
session['device_id'], cached_hit mime,
ip_address,
user_agent,
session['device_id'],
cached_hit
) )
return response return response

View File

@ -40,7 +40,7 @@ services:
# Production-ready Gunicorn command with eventlet # Production-ready Gunicorn command with eventlet
command: > command: >
sh -c "pip install -r requirements.txt && sh -c "pip install -r requirements.txt &&
gunicorn --worker-class eventlet -w 1 -b 0.0.0.0:5000 app:app" gunicorn --worker-class eventlet -w 4 -b 0.0.0.0:5000 app:app"
networks: networks:

View File

@ -237,3 +237,55 @@ footer {
opacity: 0; opacity: 0;
cursor: auto; cursor: auto;
} }
/* IMAGE GRID */
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 150px);
grid-auto-rows: 150px;
gap: 14px;
}
.image-item {
position: relative;
overflow: hidden;
border: 2px solid #ccc;
border-radius: 8px;
background: #f9f9f9;
}
/* the filename overlay, centered at bottom */
.image-item .file-name {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: 4px 0;
text-align: center;
font-size: 12px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
box-sizing: border-box;
transition: opacity 0.3s;
opacity: 1;
pointer-events: none;
}
/* once loaded, hide the filename overlay */
.image-item.loaded .file-name {
opacity: 0.5;
}
/* thumbnail styling unchanged */
.thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
}
/* pop when loaded */
.image-item.loaded .thumbnail {
transform: scale(1.05);
}

View File

@ -34,8 +34,7 @@ function paintFile() {
} }
function renderContent(data) { function renderContent(data) {
// Render breadcrumbs
// Render breadcrumbs, directories (grid view when appropriate), and files.
let breadcrumbHTML = ''; let breadcrumbHTML = '';
data.breadcrumbs.forEach((crumb, index) => { data.breadcrumbs.forEach((crumb, index) => {
breadcrumbHTML += `<a href="#" class="breadcrumb-link" data-path="${crumb.path}">${crumb.name}</a>`; breadcrumbHTML += `<a href="#" class="breadcrumb-link" data-path="${crumb.path}">${crumb.name}</a>`;
@ -45,14 +44,40 @@ function renderContent(data) {
}); });
document.getElementById('breadcrumbs').innerHTML = breadcrumbHTML; document.getElementById('breadcrumbs').innerHTML = breadcrumbHTML;
// Check for image-only directory (no subdirectories, at least one file, all images)
const isImageOnly = data.directories.length === 0
&& data.files.length > 0
&& data.files.every(file => file.file_type === 'image');
// Render directories.
let contentHTML = ''; let contentHTML = '';
if (isImageOnly) {
// Display thumbnails grid
contentHTML += '<div class="images-grid">';
data.files.forEach(file => {
const thumbUrl = `${file.path}?thumbnail=true`;
contentHTML += `
<div class="image-item">
<a href="#" class="play-file image-link"
data-url="${file.path}"
data-file-type="${file.file_type}">
<img
src="/media/${thumbUrl}"
class="thumbnail"
onload="this.closest('.image-item').classList.add('loaded')"
/>
</a>
<span class="file-name">${file.name}</span>
</div>
`;
});
contentHTML += '</div>';
} else {
// Render directories normally
if (data.directories.length > 0) { if (data.directories.length > 0) {
contentHTML += '<ul>';
// Check if every directory name is short (≤15 characters) and no files are present
const areAllShort = data.directories.every(dir => dir.name.length <= 15) && data.files.length === 0; const areAllShort = data.directories.every(dir => dir.name.length <= 15) && data.files.length === 0;
if (areAllShort & data.breadcrumbs.length != 1) { if (areAllShort && data.breadcrumbs.length !== 1) {
contentHTML += '<div class="directories-grid">'; contentHTML += '<div class="directories-grid">';
data.directories.forEach(dir => { data.directories.forEach(dir => {
contentHTML += `<div class="directory-item">📁 <a href="#" class="directory-link" data-path="${dir.path}">${dir.name}</a></div>`; contentHTML += `<div class="directory-item">📁 <a href="#" class="directory-link" data-path="${dir.path}">${dir.name}</a></div>`;
@ -63,15 +88,15 @@ function renderContent(data) {
data.directories.forEach(dir => { data.directories.forEach(dir => {
contentHTML += `<li class="directory-item">📁 <a href="#" class="directory-link" data-path="${dir.path}">${dir.name}</a></li>`; contentHTML += `<li class="directory-item">📁 <a href="#" class="directory-link" data-path="${dir.path}">${dir.name}</a></li>`;
}); });
if (data.breadcrumbs.length == 1) { if (data.breadcrumbs.length === 1) {
contentHTML += `<li class="link-item" onclick="window.location.href='/search'">🔎 <a href="/search" class="link-link">Suche</a></li>`; contentHTML += `<li class="link-item" onclick="window.location.href='/search'">🔎 <a href="/search" class="link-link">Suche</a></li>`;
} }
contentHTML += '</ul>'; contentHTML += '</ul>';
} }
} }
// Render files. // Render files (including music and non-image files)
currentMusicFiles = []; // Reset the music files array. currentMusicFiles = [];
if (data.files.length > 0) { if (data.files.length > 0) {
contentHTML += '<ul>'; contentHTML += '<ul>';
data.files.forEach((file, idx) => { data.files.forEach((file, idx) => {
@ -92,55 +117,48 @@ function renderContent(data) {
}); });
contentHTML += '</ul>'; contentHTML += '</ul>';
} }
}
// Insert the generated HTML into a container in the DOM. // Insert generated content
document.getElementById('content').innerHTML = contentHTML; document.getElementById('content').innerHTML = contentHTML;
// Now attach the event listeners for directories. // Attach event listeners for directories
document.querySelectorAll('div.directory-item').forEach(li => { document.querySelectorAll('.directory-item').forEach(item => {
li.addEventListener('click', function(e) { item.addEventListener('click', function(e) {
// Only trigger if the click did not start on an <a>.
if (!e.target.closest('a')) { if (!e.target.closest('a')) {
const anchor = li.querySelector('a.directory-link'); const anchor = item.querySelector('a.directory-link');
if (anchor) { if (anchor) anchor.click();
anchor.click();
}
} }
}); });
}); });
// Now attach the event listeners for directories. // Attach event listeners for file items (including images)
document.querySelectorAll('li.directory-item').forEach(li => { if (isImageOnly) {
li.addEventListener('click', function(e) { document.querySelectorAll('.image-item').forEach(item => {
// Only trigger if the click did not start on an <a>. item.addEventListener('click', function(e) {
if (!e.target.closest('a')) { if (!e.target.closest('a')) {
const anchor = li.querySelector('a.directory-link'); const anchor = item.querySelector('a.image-link');
if (anchor) { if (anchor) anchor.click();
anchor.click();
}
} }
}); });
}); });
} else {
// Now attach the event listeners for files. document.querySelectorAll('li.file-item').forEach(item => {
document.querySelectorAll('li.file-item').forEach(li => { item.addEventListener('click', function(e) {
li.addEventListener('click', function(e) {
// Only trigger if the click did not start on an <a>.
if (!e.target.closest('a')) { if (!e.target.closest('a')) {
const anchor = li.querySelector('a.play-file'); const anchor = item.querySelector('a.play-file');
if (anchor) { if (anchor) anchor.click();
anchor.click();
}
} }
}); });
}); });
}
// Update global variable for gallery images (only image files). // Update gallery images for lightbox or similar
currentGalleryImages = data.files currentGalleryImages = data.files
.filter(f => f.file_type === 'image') .filter(f => f.file_type === 'image')
.map(f => f.path); .map(f => f.path);
attachEventListeners(); // Reattach event listeners for newly rendered elements. attachEventListeners();
} }
// Fetch directory data from the API. // Fetch directory data from the API.

View File

@ -35,7 +35,7 @@
</head> </head>
<body> <body>
<header class="site-header"> <header class="site-header">
<a href="#"> <a href="/">
<img src="/custom_logo/logoW.png" alt="Logo" class="logo"> <img src="/custom_logo/logoW.png" alt="Logo" class="logo">
</a> </a>
<h1>{{ title_long }}</h1> <h1>{{ title_long }}</h1>

View File

@ -5,6 +5,7 @@ import whisper
import concurrent.futures import concurrent.futures
import json import json
import re import re
import yaml
# model_name = "large-v3" # model_name = "large-v3"
model_name = "medium" model_name = "medium"
@ -13,17 +14,9 @@ model_name = "medium"
start_time = 0 start_time = 0
total_audio_length = 0 total_audio_length = 0
folder_list = [ with open("transcription_folder.yml", "r", encoding="utf-8") as file:
# Speyer settings = yaml.safe_load(file)
# "\\\\10.1.0.11\\Aufnahme-stereo\\010 Gottesdienste ARCHIV\\2025", folder_list = settings.get("folder_list", [])
# "\\\\10.1.0.11\\Aufnahme-stereo\\010 Gottesdienste ARCHIV\\2016",
# "\\\\10.1.0.11\\Aufnahme-stereo\\010 Gottesdienste ARCHIV\\2015",
# "\\\\10.1.0.11\\Aufnahme-stereo\\010 Gottesdienste ARCHIV\\2014",
# Schwegenheim
"\\\\10.1.1.11\\Aufnahme-stereo\\010 Gottesdienste ARCHIV\\2025",
"\\\\10.1.1.11\\Aufnahme-stereo\\010 Gottesdienste ARCHIV\\2024"
]
def format_timestamp(seconds): def format_timestamp(seconds):
"""Format seconds into HH:MM:SS.""" """Format seconds into HH:MM:SS."""