Compare commits
13 Commits
7fc513d404
...
ffc5f67bde
| Author | SHA1 | Date | |
|---|---|---|---|
| ffc5f67bde | |||
| a5930cb506 | |||
| c7c52f0dc2 | |||
| c8c0db493f | |||
| 063f14a7b4 | |||
| 65341717cd | |||
| f0346d82ad | |||
| 6b8f27ad19 | |||
| 4abf9c14b1 | |||
| 6630a4f300 | |||
| 4db1b8d927 | |||
| 77d1672efb | |||
| f8e705ab20 |
140
app.py
140
app.py
@ -15,7 +15,6 @@ import mimetypes
|
|||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
import diskcache
|
import diskcache
|
||||||
import threading
|
import threading
|
||||||
import time
|
|
||||||
from flask_socketio import SocketIO, emit
|
from flask_socketio import SocketIO, emit
|
||||||
import geoip2.database
|
import geoip2.database
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
@ -563,7 +562,8 @@ def list_directory_contents(directory, subpath):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if entry.is_dir(follow_symlinks=False):
|
if entry.is_dir(follow_symlinks=False):
|
||||||
if entry.name in ["Transkription", "@eaDir", ".ai"]:
|
# skip system/hidden helper folders
|
||||||
|
if entry.name in ["Transkription", "@eaDir", ".app", "#recycle"]:
|
||||||
continue
|
continue
|
||||||
rel_path = os.path.join(subpath, entry.name) if subpath else entry.name
|
rel_path = os.path.join(subpath, entry.name) if subpath else entry.name
|
||||||
|
|
||||||
@ -939,87 +939,23 @@ def serve_file(subpath):
|
|||||||
_mark_request_logged(req_id)
|
_mark_request_logged(req_id)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# 5) Non-image branch: check if cached, otherwise create partial cache file
|
# 5) Non-image branch: check if cached; if not, fill cache synchronously and then serve from cache
|
||||||
try:
|
try:
|
||||||
with cache.read(subpath) as reader:
|
with cache.read(subpath) as reader:
|
||||||
file_path = reader.name
|
file_path = reader.name
|
||||||
cached_hit = True
|
cached_hit = True
|
||||||
except KeyError:
|
except KeyError:
|
||||||
cached_hit = False
|
cached_hit = False
|
||||||
|
|
||||||
# Create a temporary cache file that we'll write to and serve simultaneously
|
|
||||||
import hashlib
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
# Generate cache key similar to diskcache
|
|
||||||
cache_key = hashlib.md5(subpath.encode('utf-8')).hexdigest()
|
|
||||||
cache_dir = os.path.join(cache.directory, cache_key[:2])
|
|
||||||
os.makedirs(cache_dir, exist_ok=True)
|
|
||||||
fd, cache_file_path = tempfile.mkstemp(
|
|
||||||
prefix=f"{cache_key}_",
|
|
||||||
suffix=".tmp",
|
|
||||||
dir=cache_dir
|
|
||||||
)
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
# Write an initial chunk synchronously so the temp file exists with data
|
|
||||||
initial_bytes = 0
|
|
||||||
try:
|
try:
|
||||||
with open(full_path, 'rb') as source, open(cache_file_path, 'wb') as dest:
|
# Copy full file into diskcache (blocks the request; avoids serving partial content)
|
||||||
chunk = source.read(1024 * 1024) # 1MB
|
with open(full_path, 'rb') as source:
|
||||||
if chunk:
|
cache.set(subpath, source, read=True)
|
||||||
dest.write(chunk)
|
with cache.read(subpath) as reader:
|
||||||
dest.flush()
|
file_path = reader.name
|
||||||
initial_bytes = len(chunk)
|
app.logger.info(f"Cached {subpath}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"Failed to prime cache file for {subpath}: {e}")
|
app.logger.error(f"Cache fill failed for {subpath}: {e}")
|
||||||
if os.path.exists(cache_file_path):
|
abort(503, description="Service temporarily unavailable - cache population failed")
|
||||||
try:
|
|
||||||
os.remove(cache_file_path)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
abort(503, description="Service temporarily unavailable - cache initialization failed")
|
|
||||||
|
|
||||||
# Start copying to our cache file in chunks
|
|
||||||
def copy_to_cache_chunked(start_offset):
|
|
||||||
try:
|
|
||||||
with open(full_path, 'rb') as source, open(cache_file_path, 'ab') as dest:
|
|
||||||
source.seek(start_offset)
|
|
||||||
while True:
|
|
||||||
chunk = source.read(1024 * 1024) # 1MB chunks
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
dest.write(chunk)
|
|
||||||
dest.flush() # Ensure data is written to disk immediately
|
|
||||||
|
|
||||||
# Once complete, register with diskcache for proper management
|
|
||||||
try:
|
|
||||||
if subpath in cache:
|
|
||||||
if os.path.exists(cache_file_path):
|
|
||||||
os.remove(cache_file_path)
|
|
||||||
app.logger.info(f"Cache already populated for {subpath}, skipped duplicate registration")
|
|
||||||
return
|
|
||||||
with open(cache_file_path, 'rb') as f:
|
|
||||||
cache.set(subpath, f, read=True)
|
|
||||||
# Remove our temp file since diskcache now has it
|
|
||||||
if os.path.exists(cache_file_path):
|
|
||||||
os.remove(cache_file_path)
|
|
||||||
app.logger.info(f"Finished caching {subpath}")
|
|
||||||
except Exception as e:
|
|
||||||
app.logger.error(f"Failed to register with diskcache: {e}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
app.logger.error(f"Caching failed for {subpath}: {e}")
|
|
||||||
if os.path.exists(cache_file_path):
|
|
||||||
try:
|
|
||||||
os.remove(cache_file_path)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Start the background copy
|
|
||||||
cache_thread = threading.Thread(target=copy_to_cache_chunked, args=(initial_bytes,), daemon=True)
|
|
||||||
cache_thread.start()
|
|
||||||
file_path = cache_file_path
|
|
||||||
|
|
||||||
# 6) Build response for non-image
|
# 6) Build response for non-image
|
||||||
if as_attachment:
|
if as_attachment:
|
||||||
@ -1031,49 +967,19 @@ def serve_file(subpath):
|
|||||||
|
|
||||||
|
|
||||||
# Single send_file call with proper attachment handling
|
# Single send_file call with proper attachment handling
|
||||||
# For partial cache files, we need to handle this differently
|
response = send_file(
|
||||||
if not cached_hit:
|
file_path,
|
||||||
# Stream from the cache file as it's being written
|
mimetype=mimetype,
|
||||||
def generate():
|
conditional=True,
|
||||||
bytes_sent = 0
|
as_attachment=as_attachment,
|
||||||
with open(file_path, 'rb') as f:
|
download_name=filename if as_attachment else None
|
||||||
while bytes_sent < filesize:
|
)
|
||||||
# Read what's available
|
|
||||||
chunk = f.read(1024 * 1024) # 1MB chunks
|
if as_attachment:
|
||||||
if chunk:
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
bytes_sent += len(chunk)
|
response.headers['Content-Disposition'] = 'attachment'
|
||||||
yield chunk
|
|
||||||
else:
|
|
||||||
# No data available yet, wait a bit
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
if request.method == 'HEAD':
|
|
||||||
response = make_response('', 200)
|
|
||||||
else:
|
|
||||||
response = make_response(generate())
|
|
||||||
response.headers['Content-Type'] = mimetype
|
|
||||||
response.headers['Content-Length'] = str(filesize)
|
|
||||||
response.headers['Accept-Ranges'] = 'bytes'
|
|
||||||
if as_attachment:
|
|
||||||
response.headers['Content-Disposition'] = f'attachment; filename="{filename}"'
|
|
||||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
|
||||||
else:
|
|
||||||
response.headers['Content-Disposition'] = 'inline'
|
|
||||||
else:
|
else:
|
||||||
# Cached file - use normal send_file
|
response.headers['Content-Disposition'] = 'inline'
|
||||||
response = send_file(
|
|
||||||
file_path,
|
|
||||||
mimetype=mimetype,
|
|
||||||
conditional=True,
|
|
||||||
as_attachment=as_attachment,
|
|
||||||
download_name=filename if as_attachment else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if as_attachment:
|
|
||||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
|
||||||
response.headers['Content-Disposition'] = 'attachment'
|
|
||||||
else:
|
|
||||||
response.headers['Content-Disposition'] = 'inline'
|
|
||||||
|
|
||||||
response.headers['Cache-Control'] = 'public, max-age=86400'
|
response.headers['Cache-Control'] = 'public, max-age=86400'
|
||||||
|
|
||||||
|
|||||||
@ -2,12 +2,22 @@ import os
|
|||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from time import monotonic
|
||||||
import re
|
import re
|
||||||
import helperfunctions as hf
|
import helperfunctions as hf
|
||||||
|
|
||||||
SEARCH_DB_NAME = 'search.db'
|
SEARCH_DB_NAME = 'search.db'
|
||||||
ACCESS_LOG_DB_NAME = 'access_log.db'
|
ACCESS_LOG_DB_NAME = 'access_log.db'
|
||||||
FOLDER_CONFIG = 'folder_secret_config.json'
|
FOLDER_CONFIG = 'folder_secret_config.json'
|
||||||
|
TRANSCRIPT_DIRNAME = "Transkription"
|
||||||
|
TRANSCRIPT_EXT = ".md"
|
||||||
|
IGNORED_DIRS = {TRANSCRIPT_DIRNAME, "@eaDir", ".app", "#recycle"}
|
||||||
|
|
||||||
|
# Logging/progress tuning (keep output light by default)
|
||||||
|
LOG_STRUCTURE_DEPTH = int(os.getenv("INDEX_LOG_STRUCTURE_DEPTH", "0") or 0)
|
||||||
|
PROGRESS_EVERY_SECS = float(os.getenv("INDEX_PROGRESS_SECS", "30"))
|
||||||
|
PROGRESS_EVERY_FILES = int(os.getenv("INDEX_PROGRESS_FILES", "5000"))
|
||||||
|
MAX_ERROR_LOGS = int(os.getenv("INDEX_MAX_ERROR_LOGS", "5"))
|
||||||
|
|
||||||
# Connect to the search database.
|
# Connect to the search database.
|
||||||
search_db = sqlite3.connect(SEARCH_DB_NAME, check_same_thread=False)
|
search_db = sqlite3.connect(SEARCH_DB_NAME, check_same_thread=False)
|
||||||
@ -17,6 +27,30 @@ search_db.row_factory = sqlite3.Row
|
|||||||
access_log_db = sqlite3.connect(f'file:{ACCESS_LOG_DB_NAME}?mode=ro', uri=True)
|
access_log_db = sqlite3.connect(f'file:{ACCESS_LOG_DB_NAME}?mode=ro', uri=True)
|
||||||
access_log_db.row_factory = sqlite3.Row
|
access_log_db.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
def log(message: str):
|
||||||
|
"""Small helper to ensure console output is flushed immediately."""
|
||||||
|
print(message, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def log_permission_error(path: str, stats: dict):
|
||||||
|
"""Log permission errors sparingly to avoid noisy output."""
|
||||||
|
stats["perm_errors"] += 1
|
||||||
|
if stats["perm_errors"] <= MAX_ERROR_LOGS:
|
||||||
|
log(f"Permission denied: {path}")
|
||||||
|
elif stats["perm_errors"] == MAX_ERROR_LOGS + 1:
|
||||||
|
log("Further permission errors suppressed.")
|
||||||
|
|
||||||
|
|
||||||
|
def skip_dir(name: str) -> bool:
|
||||||
|
"""Return True when a directory name should be skipped during traversal/logging."""
|
||||||
|
return name.startswith('.') or name in IGNORED_DIRS
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(seconds: float) -> str:
|
||||||
|
total_secs = int(seconds)
|
||||||
|
minutes, secs = divmod(total_secs, 60)
|
||||||
|
return f"{minutes}m {secs:02d}s"
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""Initializes the database with the required schema."""
|
"""Initializes the database with the required schema."""
|
||||||
cursor = search_db.cursor()
|
cursor = search_db.cursor()
|
||||||
@ -48,20 +82,28 @@ def init_db():
|
|||||||
|
|
||||||
search_db.commit()
|
search_db.commit()
|
||||||
|
|
||||||
def scan_dir(directory):
|
def scan_dir(directory: str, stats: dict):
|
||||||
"""Recursively scan directories using os.scandir for improved performance."""
|
"""Iteratively scan directories using os.scandir for improved performance."""
|
||||||
try:
|
stack = [directory]
|
||||||
with os.scandir(directory) as it:
|
while stack:
|
||||||
for entry in it:
|
current = stack.pop()
|
||||||
if entry.is_dir(follow_symlinks=False):
|
stats["dirs"] += 1
|
||||||
# Skip transcription directories immediately.
|
try:
|
||||||
if entry.name.lower() == "transkription":
|
with os.scandir(current) as it:
|
||||||
continue
|
for entry in it:
|
||||||
yield from scan_dir(entry.path)
|
try:
|
||||||
elif entry.is_file(follow_symlinks=False):
|
if entry.is_dir(follow_symlinks=False):
|
||||||
yield entry
|
# Skip unwanted directories immediately.
|
||||||
except PermissionError:
|
if skip_dir(entry.name):
|
||||||
return
|
stats["skipped_dirs"] += 1
|
||||||
|
continue
|
||||||
|
stack.append(entry.path)
|
||||||
|
elif entry.is_file(follow_symlinks=False):
|
||||||
|
yield entry
|
||||||
|
except PermissionError:
|
||||||
|
log_permission_error(entry.path, stats)
|
||||||
|
except PermissionError:
|
||||||
|
log_permission_error(current, stats)
|
||||||
|
|
||||||
def get_hit_count(relative_path):
|
def get_hit_count(relative_path):
|
||||||
"""Returns the hit count for a given file from the access log database."""
|
"""Returns the hit count for a given file from the access log database."""
|
||||||
@ -71,8 +113,77 @@ def get_hit_count(relative_path):
|
|||||||
return row["hit_count"] if row else 0
|
return row["hit_count"] if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_hit_counts_for_basefolder(basefolder: str) -> dict:
|
||||||
|
"""Return a map of rel_path -> hit_count for all files under a basefolder."""
|
||||||
|
cursor = access_log_db.cursor()
|
||||||
|
pattern = f"{basefolder}/%"
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT rel_path, COUNT(*) AS hit_count FROM file_access_log WHERE rel_path LIKE ? GROUP BY rel_path",
|
||||||
|
(pattern,)
|
||||||
|
)
|
||||||
|
return {row["rel_path"]: row["hit_count"] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def build_transcript_index(transcript_dir: str, stats: dict):
|
||||||
|
"""Return a dict of basename -> transcript path for a transcript directory."""
|
||||||
|
try:
|
||||||
|
with os.scandir(transcript_dir) as it:
|
||||||
|
index = {}
|
||||||
|
for entry in it:
|
||||||
|
if not entry.is_file(follow_symlinks=False):
|
||||||
|
continue
|
||||||
|
name = entry.name
|
||||||
|
if not name.endswith(TRANSCRIPT_EXT):
|
||||||
|
continue
|
||||||
|
index[name[:-len(TRANSCRIPT_EXT)]] = entry.path
|
||||||
|
return index
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except PermissionError:
|
||||||
|
log_permission_error(transcript_dir, stats)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def log_structure(root_path, max_depth=None, show_files=False):
|
||||||
|
"""
|
||||||
|
Log folder structure up to max_depth levels (root = depth 1).
|
||||||
|
If max_depth is None, traverse all depths. Files are logged only when show_files is True.
|
||||||
|
"""
|
||||||
|
depth_label = "all" if max_depth is None else f"<= {max_depth}"
|
||||||
|
log(f"Folder structure (depth {depth_label}) for '{root_path}':")
|
||||||
|
|
||||||
|
def _walk(path, depth):
|
||||||
|
if max_depth is not None and depth > max_depth:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with os.scandir(path) as it:
|
||||||
|
entries = sorted(it, key=lambda e: (not e.is_dir(follow_symlinks=False), e.name.lower()))
|
||||||
|
for entry in entries:
|
||||||
|
if entry.is_dir(follow_symlinks=False):
|
||||||
|
if skip_dir(entry.name):
|
||||||
|
continue
|
||||||
|
indent = " " * (depth - 1)
|
||||||
|
log(f"{indent}- {entry.name}/")
|
||||||
|
_walk(entry.path, depth + 1)
|
||||||
|
elif show_files:
|
||||||
|
indent = " " * (depth - 1)
|
||||||
|
log(f"{indent}- {entry.name}")
|
||||||
|
except PermissionError:
|
||||||
|
indent = " " * (depth - 1)
|
||||||
|
log(f"{indent}- [permission denied]")
|
||||||
|
|
||||||
|
_walk(root_path, depth=1)
|
||||||
|
|
||||||
|
|
||||||
|
def log_file(relative_path: str, filename: str):
|
||||||
|
"""Debug helper to log each file that is indexed."""
|
||||||
|
log(f" file: {relative_path} ({filename})")
|
||||||
|
|
||||||
|
|
||||||
def updatefileindex():
|
def updatefileindex():
|
||||||
|
total_start = monotonic()
|
||||||
cursor = search_db.cursor()
|
cursor = search_db.cursor()
|
||||||
|
totals = {"folders": 0, "scanned": 0, "deleted": 0}
|
||||||
|
|
||||||
# Load folder configuration from JSON file.
|
# Load folder configuration from JSON file.
|
||||||
with open(FOLDER_CONFIG, "r", encoding="utf-8") as f:
|
with open(FOLDER_CONFIG, "r", encoding="utf-8") as f:
|
||||||
@ -81,68 +192,120 @@ def updatefileindex():
|
|||||||
# Process each configured base folder.
|
# Process each configured base folder.
|
||||||
for config in config_data:
|
for config in config_data:
|
||||||
for folder in config.get("folders", []):
|
for folder in config.get("folders", []):
|
||||||
|
totals["folders"] += 1
|
||||||
foldername = folder.get("foldername")
|
foldername = folder.get("foldername")
|
||||||
print(f"Processing folder: {foldername}")
|
log(f"Processing folder: {foldername}")
|
||||||
raw_folderpath = folder.get("folderpath")
|
raw_folderpath = folder.get("folderpath")
|
||||||
norm_folderpath = os.path.normpath(raw_folderpath)
|
norm_folderpath = os.path.normpath(raw_folderpath)
|
||||||
|
# Optional shallow structure log (off by default)
|
||||||
|
if LOG_STRUCTURE_DEPTH > 0:
|
||||||
|
log_structure(norm_folderpath, max_depth=LOG_STRUCTURE_DEPTH, show_files=False)
|
||||||
# Precompute the length of the base folder path (plus one for the separator)
|
# Precompute the length of the base folder path (plus one for the separator)
|
||||||
base_len = len(norm_folderpath) + 1
|
base_prefix = norm_folderpath + os.sep
|
||||||
|
base_len = len(base_prefix)
|
||||||
|
# Prefetch hit counts for this basefolder to avoid per-file queries
|
||||||
|
hitcount_map = get_hit_counts_for_basefolder(foldername)
|
||||||
|
|
||||||
# Accumulate scanned file data and keys for this base folder.
|
# Accumulate scanned file data and keys for this base folder.
|
||||||
scanned_files = [] # Each entry: (relative_path, basefolder, filename, filetype, transcript, hitcount)
|
scanned_files = [] # Each entry: (relative_path, basefolder, filename, filetype, transcript, hitcount)
|
||||||
current_keys = set()
|
current_keys = set()
|
||||||
for entry in scan_dir(norm_folderpath):
|
scan_stats = {"dirs": 0, "skipped_dirs": 0, "perm_errors": 0}
|
||||||
|
transcript_cache = {}
|
||||||
|
transcripts_read = 0
|
||||||
|
transcript_errors = 0
|
||||||
|
|
||||||
|
site = None
|
||||||
|
if foldername == 'Gottesdienste Speyer':
|
||||||
|
site = 'Speyer'
|
||||||
|
elif foldername == 'Gottesdienste Schwegenheim':
|
||||||
|
site = 'Schwegenheim'
|
||||||
|
|
||||||
|
start_time = monotonic()
|
||||||
|
last_log_time = start_time
|
||||||
|
next_log_count = PROGRESS_EVERY_FILES
|
||||||
|
scanned_count = 0
|
||||||
|
last_log_count = 0
|
||||||
|
|
||||||
|
extract_structure = hf.extract_structure_from_string
|
||||||
|
extract_date = hf.extract_date_from_string
|
||||||
|
|
||||||
|
for entry in scan_dir(norm_folderpath, scan_stats):
|
||||||
transcript = None
|
transcript = None
|
||||||
entry_path = os.path.normpath(entry.path)
|
scanned_count += 1
|
||||||
|
entry_path = entry.path
|
||||||
# Get relative part by slicing if possible.
|
# Get relative part by slicing if possible.
|
||||||
if entry_path.startswith(norm_folderpath):
|
if entry_path.startswith(base_prefix):
|
||||||
rel_part = entry_path[base_len:]
|
rel_part = entry_path[base_len:]
|
||||||
else:
|
else:
|
||||||
rel_part = os.path.relpath(entry_path, norm_folderpath)
|
rel_part = os.path.relpath(entry_path, norm_folderpath)
|
||||||
# Prepend the foldername so it becomes part of the stored relative path.
|
# Prepend the foldername so it becomes part of the stored relative path.
|
||||||
relative_path = os.path.join(foldername, rel_part).replace(os.sep, '/')
|
rel_part = rel_part.replace(os.sep, '/')
|
||||||
filetype = os.path.splitext(entry.name)[1].lower()
|
relative_path = f"{foldername}/{rel_part}"
|
||||||
|
name_root, name_ext = os.path.splitext(entry.name)
|
||||||
|
filetype = name_ext.lower()
|
||||||
|
|
||||||
if filetype not in ['.mp3', '.wav', '.ogg', '.m4a', '.flac']:
|
# Retrieve the hit count for this file from pre-fetched map.
|
||||||
# Skip non-audio files.
|
hit_count = hitcount_map.get(relative_path, 0)
|
||||||
continue
|
|
||||||
|
|
||||||
# Retrieve the hit count for this file.
|
|
||||||
hit_count = get_hit_count(relative_path)
|
|
||||||
|
|
||||||
# Determine the site
|
|
||||||
if foldername == 'Gottesdienste Speyer':
|
|
||||||
site = 'Speyer'
|
|
||||||
elif foldername == 'Gottesdienste Schwegenheim':
|
|
||||||
site = 'Schwegenheim'
|
|
||||||
else:
|
|
||||||
site = None
|
|
||||||
|
|
||||||
# Check for a corresponding transcript file in a sibling "Transkription" folder.
|
# Check for a corresponding transcript file in a sibling "Transkription" folder.
|
||||||
parent_dir = os.path.dirname(entry_path)
|
parent_dir = os.path.dirname(entry_path)
|
||||||
transcript_dir = os.path.join(parent_dir, "Transkription")
|
transcript_index = transcript_cache.get(parent_dir)
|
||||||
transcript_filename = os.path.splitext(entry.name)[0] + ".md"
|
if transcript_index is None and parent_dir not in transcript_cache:
|
||||||
transcript_path = os.path.join(transcript_dir, transcript_filename)
|
transcript_dir = os.path.join(parent_dir, TRANSCRIPT_DIRNAME)
|
||||||
if os.path.exists(transcript_path):
|
transcript_index = build_transcript_index(transcript_dir, scan_stats)
|
||||||
try:
|
transcript_cache[parent_dir] = transcript_index
|
||||||
with open(transcript_path, 'r', encoding='utf-8') as tf:
|
if transcript_index:
|
||||||
transcript = tf.read()
|
transcript_path = transcript_index.get(name_root)
|
||||||
except Exception:
|
if transcript_path:
|
||||||
transcript = None
|
try:
|
||||||
|
with open(transcript_path, 'r', encoding='utf-8') as tf:
|
||||||
|
transcript = tf.read()
|
||||||
|
transcripts_read += 1
|
||||||
|
except Exception:
|
||||||
|
transcript_errors += 1
|
||||||
|
|
||||||
category, titel, name = hf.extract_structure_from_string(entry.name)
|
category, titel, name = extract_structure(entry.name)
|
||||||
|
|
||||||
performance_date = hf.extract_date_from_string(relative_path)
|
performance_date = extract_date(relative_path)
|
||||||
|
|
||||||
scanned_files.append((relative_path, foldername, entry.name, filetype, category, titel, name, performance_date, site, transcript, hit_count))
|
scanned_files.append((relative_path, foldername, entry.name, filetype, category, titel, name, performance_date, site, transcript, hit_count))
|
||||||
current_keys.add((relative_path, entry.name))
|
current_keys.add((relative_path, entry.name))
|
||||||
|
|
||||||
|
# Light progress output
|
||||||
|
now = monotonic()
|
||||||
|
if scanned_count >= next_log_count or (now - last_log_time) >= PROGRESS_EVERY_SECS:
|
||||||
|
elapsed = max(now - start_time, 0.0001)
|
||||||
|
window_elapsed = max(now - last_log_time, 0.0001)
|
||||||
|
window_count = scanned_count - last_log_count
|
||||||
|
window_rate = window_count / window_elapsed
|
||||||
|
log(f" progress: {scanned_count} files, {scan_stats['dirs']} dirs, {window_rate:.1f} files/s")
|
||||||
|
last_log_time = now
|
||||||
|
last_log_count = scanned_count
|
||||||
|
next_log_count = scanned_count + PROGRESS_EVERY_FILES
|
||||||
|
|
||||||
|
# Progress indicator
|
||||||
|
dir_count = scan_stats["dirs"]
|
||||||
|
file_count = scanned_count
|
||||||
|
elapsed = max(monotonic() - start_time, 0.0001)
|
||||||
|
avg_rate = file_count / elapsed
|
||||||
|
log(f"Scan summary for '{foldername}': {dir_count} dirs, {file_count} files, {avg_rate:.1f} files/s avg")
|
||||||
|
if scan_stats["skipped_dirs"]:
|
||||||
|
log(f" skipped dirs: {scan_stats['skipped_dirs']}")
|
||||||
|
if scan_stats["perm_errors"]:
|
||||||
|
log(f" permission errors: {scan_stats['perm_errors']}")
|
||||||
|
if transcripts_read or transcript_errors:
|
||||||
|
log(f" transcripts: {transcripts_read} read, {transcript_errors} errors")
|
||||||
|
log("updating database...")
|
||||||
|
scan_duration = format_duration(elapsed)
|
||||||
|
|
||||||
# Remove database entries for files under this base folder that are no longer on disk.
|
# Remove database entries for files under this base folder that are no longer on disk.
|
||||||
pattern = foldername + os.sep + '%'
|
pattern = foldername + os.sep + '%'
|
||||||
cursor.execute("SELECT id, relative_path, filename FROM files WHERE relative_path LIKE ?", (pattern,))
|
cursor.execute("SELECT id, relative_path, filename FROM files WHERE relative_path LIKE ?", (pattern,))
|
||||||
db_rows = cursor.fetchall()
|
db_rows = cursor.fetchall()
|
||||||
keys_in_db = set((row["relative_path"], row["filename"]) for row in db_rows)
|
keys_in_db = set((row["relative_path"], row["filename"]) for row in db_rows)
|
||||||
keys_to_delete = keys_in_db - current_keys
|
keys_to_delete = keys_in_db - current_keys
|
||||||
|
deleted_count = len(keys_to_delete)
|
||||||
|
totals["deleted"] += deleted_count
|
||||||
for key in keys_to_delete:
|
for key in keys_to_delete:
|
||||||
cursor.execute("DELETE FROM files WHERE relative_path = ? AND filename = ?", key)
|
cursor.execute("DELETE FROM files WHERE relative_path = ? AND filename = ?", key)
|
||||||
|
|
||||||
@ -154,7 +317,14 @@ def updatefileindex():
|
|||||||
|
|
||||||
# Commit changes after processing this base folder.
|
# Commit changes after processing this base folder.
|
||||||
search_db.commit()
|
search_db.commit()
|
||||||
|
folder_scanned = scanned_count
|
||||||
|
totals["scanned"] += folder_scanned
|
||||||
|
log(f"Indexed {folder_scanned} files (deleted {deleted_count}) in '{foldername}'")
|
||||||
|
log(f"Scan duration for '{foldername}': {scan_duration}")
|
||||||
|
|
||||||
|
total_elapsed = max(monotonic() - total_start, 0.0001)
|
||||||
|
log(f"Index update finished: folders={totals['folders']}, files indexed={totals['scanned']}, removed={totals['deleted']}")
|
||||||
|
log(f"Total index duration: {format_duration(total_elapsed)}")
|
||||||
return "File index updated successfully"
|
return "File index updated successfully"
|
||||||
|
|
||||||
def convert_dates(search_db,
|
def convert_dates(search_db,
|
||||||
|
|||||||
30
search.py
30
search.py
@ -12,12 +12,23 @@ search_db.row_factory = sqlite3.Row
|
|||||||
with open("app_config.json", 'r') as file:
|
with open("app_config.json", 'r') as file:
|
||||||
app_config = json.load(file)
|
app_config = json.load(file)
|
||||||
|
|
||||||
|
FILETYPE_GROUPS = {
|
||||||
|
'audio': ('.mp3', '.wav', '.ogg', '.m4a', '.flac'),
|
||||||
|
'video': ('.mp4', '.mov', '.mkv', '.avi', '.webm'),
|
||||||
|
'image': ('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff')
|
||||||
|
}
|
||||||
|
ALL_GROUP_EXTS = tuple(sorted({ext for group in FILETYPE_GROUPS.values() for ext in group}))
|
||||||
|
|
||||||
def searchcommand():
|
def searchcommand():
|
||||||
query = request.form.get("query", "").strip()
|
query = request.form.get("query", "").strip()
|
||||||
category = request.form.get("category", "").strip()
|
category = request.form.get("category", "").strip()
|
||||||
searchfolder = request.form.get("folder", "").strip()
|
searchfolder = request.form.get("folder", "").strip()
|
||||||
datefrom = request.form.get("datefrom", "").strip()
|
datefrom = request.form.get("datefrom", "").strip()
|
||||||
dateto = request.form.get("dateto", "").strip()
|
dateto = request.form.get("dateto", "").strip()
|
||||||
|
filetypes = [ft.strip().lower() for ft in request.form.getlist("filetype") if ft.strip()]
|
||||||
|
if not filetypes:
|
||||||
|
# Default to audio when nothing selected
|
||||||
|
filetypes = ['audio']
|
||||||
|
|
||||||
include_transcript = request.form.get("includeTranscript") in ["true", "on"]
|
include_transcript = request.form.get("includeTranscript") in ["true", "on"]
|
||||||
words = [w for w in query.split() if w]
|
words = [w for w in query.split() if w]
|
||||||
@ -72,6 +83,25 @@ def searchcommand():
|
|||||||
if datefrom or dateto:
|
if datefrom or dateto:
|
||||||
conditions.append("performance_date IS NOT NULL")
|
conditions.append("performance_date IS NOT NULL")
|
||||||
|
|
||||||
|
# Filetype filters (multiple selection)
|
||||||
|
selected_groups = [ft for ft in filetypes if ft in FILETYPE_GROUPS]
|
||||||
|
include_other = 'other' in filetypes
|
||||||
|
|
||||||
|
# If not all groups selected, apply filter
|
||||||
|
if set(filetypes) != {'audio', 'video', 'image', 'other'}:
|
||||||
|
clauses = []
|
||||||
|
if selected_groups:
|
||||||
|
ext_list = tuple({ext for g in selected_groups for ext in FILETYPE_GROUPS[g]})
|
||||||
|
placeholders = ",".join("?" for _ in ext_list)
|
||||||
|
clauses.append(f"filetype IN ({placeholders})")
|
||||||
|
params.extend(ext_list)
|
||||||
|
if include_other:
|
||||||
|
placeholders = ",".join("?" for _ in ALL_GROUP_EXTS)
|
||||||
|
clauses.append(f"(filetype IS NULL OR filetype = '' OR filetype NOT IN ({placeholders}))")
|
||||||
|
params.extend(ALL_GROUP_EXTS)
|
||||||
|
if clauses:
|
||||||
|
conditions.append("(" + " OR ".join(clauses) + ")")
|
||||||
|
|
||||||
# Build and execute SQL
|
# Build and execute SQL
|
||||||
sql = "SELECT * FROM files"
|
sql = "SELECT * FROM files"
|
||||||
if conditions:
|
if conditions:
|
||||||
|
|||||||
@ -19,6 +19,32 @@ body {
|
|||||||
var(--light-background);
|
var(--light-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Subtle oversized logo watermark on all main sections */
|
||||||
|
main {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
main::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 100px 0 0 0;
|
||||||
|
background-image: url("/custom_logo/logoB.png");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center top;
|
||||||
|
background-size: 140%;
|
||||||
|
opacity: 0.03;
|
||||||
|
filter: blur(3px) saturate(90%) brightness(1.02);
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5; /* bring logo in front */
|
||||||
|
}
|
||||||
|
|
||||||
|
main > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Header styles */
|
/* Header styles */
|
||||||
.site-header {
|
.site-header {
|
||||||
background: linear-gradient(120deg, var(--dark-background), #13253f);
|
background: linear-gradient(120deg, var(--dark-background), #13253f);
|
||||||
@ -79,6 +105,11 @@ body {
|
|||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Spacing for alerts so they don't stick to edges or neighbors */
|
||||||
|
.alert {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Carded content shell */
|
/* Carded content shell */
|
||||||
main.tab-content,
|
main.tab-content,
|
||||||
section.tab-content {
|
section.tab-content {
|
||||||
@ -109,6 +140,10 @@ search {
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-query-wrap .input-group {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.input-clear-btn {
|
.input-clear-btn {
|
||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
border-bottom-left-radius: 0;
|
border-bottom-left-radius: 0;
|
||||||
@ -1165,3 +1200,10 @@ footer .audio-player-container {
|
|||||||
color: var(--brand-ink);
|
color: var(--brand-ink);
|
||||||
border-color: var(--brand-navy);
|
border-color: var(--brand-navy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Highlight a file when opened from search */
|
||||||
|
.search-highlight {
|
||||||
|
outline: 2px solid #f6c344;
|
||||||
|
background-color: rgba(246, 195, 68, 0.25);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,14 +1,23 @@
|
|||||||
.audio-player-container {
|
.audio-player-container {
|
||||||
background: linear-gradient(135deg, #f9fbff, #eef4ff);
|
background: linear-gradient(145deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.04));
|
||||||
|
backdrop-filter: blur(10px) saturate(135%);
|
||||||
|
-webkit-backdrop-filter: blur(10px) saturate(135%);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
box-shadow: 0 12px 26px rgba(15, 23, 42, 0.12);
|
box-shadow:
|
||||||
border: 8px solid #0b1220;
|
0 12px 30px rgba(15, 23, 42, 0.22),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.24);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.55);
|
||||||
|
outline: 1.5px solid rgba(12, 18, 32, 0.3); /* slightly stronger edge so front/back separate */
|
||||||
|
outline-offset: -1px;
|
||||||
|
background-clip: padding-box; /* keep border separate from glass */
|
||||||
padding: 12px 12px;
|
padding: 12px 12px;
|
||||||
width: min(600px, 100%);
|
width: min(600px, 100%);
|
||||||
display: none;
|
display: none;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
|
overflow: hidden; /* keep the glass blur clean at the edges */
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Now Playing Info */
|
/* Now Playing Info */
|
||||||
@ -44,11 +53,13 @@
|
|||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 0.5em;
|
height: 0.5em;
|
||||||
background-color: #e6edf7;
|
background-color: rgba(255, 255, 255, 0.12);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background-size: 0% 100%;
|
background-size: 0% 100%;
|
||||||
background-image: linear-gradient(90deg, #0b1220, #0f172a);
|
background-image: linear-gradient(90deg, rgba(11, 18, 32, 0.9), rgba(15, 23, 42, 0.9));
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.2);
|
||||||
|
background-clip: padding-box; /* keep gradient clean inside the border */
|
||||||
appearance: none;
|
appearance: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
@ -160,3 +171,61 @@
|
|||||||
.timeline::-ms-thumb {
|
.timeline::-ms-thumb {
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Minimize button */
|
||||||
|
.minimize-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.65);
|
||||||
|
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.16);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimize-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimize-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimize-button svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimize-button .icon-expand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsed state */
|
||||||
|
.audio-player-container.collapsed {
|
||||||
|
padding: 8px 48px 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-container.collapsed .audio-player,
|
||||||
|
.audio-player-container.collapsed .now-playing-info {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-container.collapsed .minimize-button .icon-collapse {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player-container.collapsed .minimize-button .icon-expand {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|||||||
@ -6,7 +6,8 @@ class SimpleAudioPlayer {
|
|||||||
infoSelector = '#timeInfo',
|
infoSelector = '#timeInfo',
|
||||||
nowPlayingSelector = '#nowPlayingInfo',
|
nowPlayingSelector = '#nowPlayingInfo',
|
||||||
containerSelector = '#audioPlayerContainer',
|
containerSelector = '#audioPlayerContainer',
|
||||||
footerSelector = 'footer'
|
footerSelector = 'footer',
|
||||||
|
minimizeBtnSelector = '.minimize-button'
|
||||||
} = {}) {
|
} = {}) {
|
||||||
// Elements
|
// Elements
|
||||||
this.audio = document.querySelector(audioSelector);
|
this.audio = document.querySelector(audioSelector);
|
||||||
@ -16,12 +17,13 @@ class SimpleAudioPlayer {
|
|||||||
this.nowInfo = document.querySelector(nowPlayingSelector);
|
this.nowInfo = document.querySelector(nowPlayingSelector);
|
||||||
this.container = document.querySelector(containerSelector);
|
this.container = document.querySelector(containerSelector);
|
||||||
this.footer = document.querySelector(footerSelector);
|
this.footer = document.querySelector(footerSelector);
|
||||||
|
this.minBtn = document.querySelector(minimizeBtnSelector);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
this.isSeeking = false;
|
this.isSeeking = false;
|
||||||
this.rafId = null;
|
this.rafId = null;
|
||||||
this.abortCtrl = null;
|
this.abortCtrl = null;
|
||||||
this._posInterval = null;
|
this._hasMetadata = false;
|
||||||
|
|
||||||
// Pre-compute icons once
|
// Pre-compute icons once
|
||||||
const fill = getComputedStyle(document.documentElement)
|
const fill = getComputedStyle(document.documentElement)
|
||||||
@ -51,6 +53,33 @@ class SimpleAudioPlayer {
|
|||||||
this._initMediaSession();
|
this._initMediaSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the current position to MediaSession so lockscreen / BT UIs stay in sync.
|
||||||
|
* Keeps values strict but avoids over-clamping that can confuse some platforms.
|
||||||
|
*/
|
||||||
|
_pushPositionState(force = false) {
|
||||||
|
if (!navigator.mediaSession?.setPositionState) return;
|
||||||
|
if (!this._hasMetadata) return;
|
||||||
|
|
||||||
|
const duration = this.audio.duration;
|
||||||
|
const position = this.audio.currentTime;
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) return;
|
||||||
|
if (!Number.isFinite(position) || position < 0) return;
|
||||||
|
|
||||||
|
const playbackRate = this.audio.paused
|
||||||
|
? 0
|
||||||
|
: (Number.isFinite(this.audio.playbackRate) ? this.audio.playbackRate : 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ('playbackState' in navigator.mediaSession) {
|
||||||
|
navigator.mediaSession.playbackState = this.audio.paused ? 'paused' : 'playing';
|
||||||
|
}
|
||||||
|
navigator.mediaSession.setPositionState({ duration, playbackRate, position });
|
||||||
|
} catch (err) {
|
||||||
|
if (force) console.warn('setPositionState failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Minimal SVG helper
|
// Minimal SVG helper
|
||||||
svg(pathD, fill) {
|
svg(pathD, fill) {
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="${fill}">
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="${fill}">
|
||||||
@ -60,42 +89,59 @@ class SimpleAudioPlayer {
|
|||||||
|
|
||||||
_bindEvents() {
|
_bindEvents() {
|
||||||
this.playBtn.addEventListener('click', () => this.togglePlay());
|
this.playBtn.addEventListener('click', () => this.togglePlay());
|
||||||
|
if (this.minBtn) {
|
||||||
|
this.minBtn.addEventListener('click', () => this.toggleCollapse());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.audio.addEventListener('loadstart', () => {
|
||||||
|
this._hasMetadata = false;
|
||||||
|
this.timeline.min = 0;
|
||||||
|
this.timeline.max = 0;
|
||||||
|
this.timeline.value = 0;
|
||||||
|
this.timeline.style.backgroundSize = '0% 100%';
|
||||||
|
this.timeInfo.textContent = '00:00 / --:--';
|
||||||
|
});
|
||||||
|
|
||||||
this.audio.addEventListener('loadedmetadata', () => {
|
this.audio.addEventListener('loadedmetadata', () => {
|
||||||
|
this._hasMetadata = true;
|
||||||
this.timeline.min = 0;
|
this.timeline.min = 0;
|
||||||
this.timeline.max = this.audio.duration;
|
this.timeline.max = this.audio.duration;
|
||||||
this.timeline.value = 0;
|
this.timeline.value = 0;
|
||||||
this.timeline.style.backgroundSize = '0% 100%';
|
this.timeline.style.backgroundSize = '0% 100%';
|
||||||
|
this._pushPositionState(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.audio.addEventListener('play', () => this._updatePlayState());
|
this.audio.addEventListener('play', () => {
|
||||||
this.audio.addEventListener('pause', () => this._updatePlayState());
|
this._updatePlayState();
|
||||||
this.audio.addEventListener('ended', () => this.playBtn.innerHTML = this.icons.play);
|
this._pushPositionState(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.audio.addEventListener('playing', () => this._pushPositionState(true));
|
||||||
|
|
||||||
|
this.audio.addEventListener('pause', () => {
|
||||||
|
this._updatePlayState();
|
||||||
|
this._pushPositionState(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.audio.addEventListener('ratechange', () => this._pushPositionState(true));
|
||||||
|
this.audio.addEventListener('ended', () => {
|
||||||
|
this.playBtn.innerHTML = this.icons.play;
|
||||||
|
this._pushPositionState(true);
|
||||||
|
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused';
|
||||||
|
});
|
||||||
|
|
||||||
// Native timeupdate => update timeline
|
// Native timeupdate => update timeline
|
||||||
this.audio.addEventListener('timeupdate', () => this.updateTimeline());
|
this.audio.addEventListener('timeupdate', () => this.updateTimeline());
|
||||||
|
|
||||||
// Fallback interval for throttled mobile
|
// Keep position in sync when page visibility changes
|
||||||
this._interval = setInterval(() => {
|
document.addEventListener('visibilitychange', () => this._pushPositionState(true));
|
||||||
if (!this.audio.paused && !this.isSeeking) {
|
|
||||||
this.updateTimeline();
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
// Unified seek input
|
// Unified seek input
|
||||||
this.timeline.addEventListener('input', () => {
|
this.timeline.addEventListener('input', () => {
|
||||||
this.isSeeking = true;
|
this.isSeeking = true;
|
||||||
this.audio.currentTime = this.timeline.value;
|
this.audio.currentTime = this.timeline.value;
|
||||||
|
|
||||||
// immediate Android sync
|
this._pushPositionState(true); // immediate Android sync
|
||||||
if (navigator.mediaSession?.setPositionState && Number.isFinite(this.audio.duration)) {
|
|
||||||
navigator.mediaSession.setPositionState({
|
|
||||||
duration: this.audio.duration,
|
|
||||||
playbackRate: this.audio.playbackRate,
|
|
||||||
position: this.audio.currentTime
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateTimeline();
|
this.updateTimeline();
|
||||||
this.isSeeking = false;
|
this.isSeeking = false;
|
||||||
});
|
});
|
||||||
@ -107,9 +153,7 @@ class SimpleAudioPlayer {
|
|||||||
|
|
||||||
_updatePlayState() {
|
_updatePlayState() {
|
||||||
this.playBtn.innerHTML = this.audio.paused ? this.icons.play : this.icons.pause;
|
this.playBtn.innerHTML = this.audio.paused ? this.icons.play : this.icons.pause;
|
||||||
if ('mediaSession' in navigator) {
|
this._pushPositionState(true); // lock‑screen refresh on play/pause toggle
|
||||||
navigator.mediaSession.playbackState = this.audio.paused ? 'paused' : 'playing';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_formatTime(sec) {
|
_formatTime(sec) {
|
||||||
@ -118,7 +162,7 @@ class SimpleAudioPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateTimeline() {
|
updateTimeline() {
|
||||||
if (!this.audio.duration || this.isSeeking) return;
|
if (!this._hasMetadata || !this.audio.duration || this.isSeeking) return;
|
||||||
|
|
||||||
// 1) Move the thumb
|
// 1) Move the thumb
|
||||||
this.timeline.value = this.audio.currentTime;
|
this.timeline.value = this.audio.currentTime;
|
||||||
@ -129,12 +173,14 @@ class SimpleAudioPlayer {
|
|||||||
this.timeInfo.textContent =
|
this.timeInfo.textContent =
|
||||||
`${this._formatTime(this.audio.currentTime)} / ${this._formatTime(this.audio.duration)}`;
|
`${this._formatTime(this.audio.currentTime)} / ${this._formatTime(this.audio.duration)}`;
|
||||||
// 4) Push to Android widget
|
// 4) Push to Android widget
|
||||||
if (navigator.mediaSession?.setPositionState && Number.isFinite(this.audio.duration)) {
|
this._pushPositionState();
|
||||||
navigator.mediaSession.setPositionState({
|
}
|
||||||
duration: this.audio.duration,
|
|
||||||
playbackRate: this.audio.playbackRate,
|
toggleCollapse() {
|
||||||
position: this.audio.currentTime
|
const collapsed = this.container.classList.toggle('collapsed');
|
||||||
});
|
if (this.minBtn) {
|
||||||
|
this.minBtn.setAttribute('aria-expanded', (!collapsed).toString());
|
||||||
|
this.minBtn.setAttribute('aria-label', collapsed ? 'Player ausklappen' : 'Player einklappen');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,7 +219,20 @@ class SimpleAudioPlayer {
|
|||||||
const requestId = reqId || (crypto.randomUUID ? crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2)));
|
const requestId = reqId || (crypto.randomUUID ? crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2)));
|
||||||
const urlWithReq = `/media/${relUrl}${relUrl.includes('?') ? '&' : '?'}req=${encodeURIComponent(requestId)}`;
|
const urlWithReq = `/media/${relUrl}${relUrl.includes('?') ? '&' : '?'}req=${encodeURIComponent(requestId)}`;
|
||||||
|
|
||||||
|
// Reset any residual state from the previous track before we fetch the new one
|
||||||
|
this._hasMetadata = false;
|
||||||
this.audio.pause();
|
this.audio.pause();
|
||||||
|
this.audio.removeAttribute('src');
|
||||||
|
this.audio.load();
|
||||||
|
this.timeline.min = 0;
|
||||||
|
this.timeline.max = 0;
|
||||||
|
this.timeline.value = 0;
|
||||||
|
this.timeline.style.backgroundSize = '0% 100%';
|
||||||
|
this.timeInfo.textContent = '00:00 / --:--';
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
navigator.mediaSession.playbackState = 'paused';
|
||||||
|
}
|
||||||
|
|
||||||
this.container.style.display = 'block';
|
this.container.style.display = 'block';
|
||||||
this.nowInfo.textContent = 'Loading…';
|
this.nowInfo.textContent = 'Loading…';
|
||||||
|
|
||||||
@ -188,6 +247,7 @@ class SimpleAudioPlayer {
|
|||||||
if (!head.ok) throw new Error(`Status ${head.status}`);
|
if (!head.ok) throw new Error(`Status ${head.status}`);
|
||||||
|
|
||||||
this.audio.src = urlWithReq;
|
this.audio.src = urlWithReq;
|
||||||
|
this.audio.load();
|
||||||
await this.audio.play();
|
await this.audio.play();
|
||||||
|
|
||||||
// Full breadcrumb
|
// Full breadcrumb
|
||||||
@ -254,16 +314,6 @@ class SimpleAudioPlayer {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Heartbeat for widget
|
|
||||||
this._posInterval = setInterval(() => {
|
|
||||||
if (!this.audio.paused && navigator.mediaSession?.setPositionState && Number.isFinite(this.audio.duration)) {
|
|
||||||
navigator.mediaSession.setPositionState({
|
|
||||||
duration: this.audio.duration,
|
|
||||||
playbackRate: this.audio.playbackRate,
|
|
||||||
position: this.audio.currentTime
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,17 +5,26 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const resultsDiv = document.getElementById('results');
|
const resultsDiv = document.getElementById('results');
|
||||||
resultsDiv.innerHTML = '<h5>Suchergebnisse:</h5>';
|
resultsDiv.innerHTML = '<h5>Suchergebnisse:</h5>';
|
||||||
|
|
||||||
|
const audioExts = ['.mp3', '.wav', '.ogg', '.m4a', '.flac'];
|
||||||
|
|
||||||
if (data.results && data.results.length > 0) {
|
if (data.results && data.results.length > 0) {
|
||||||
data.results.forEach(file => {
|
data.results.forEach(file => {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
const filenameWithoutExtension = file.filename.split('.').slice(0, -1).join('.');
|
const filenameWithoutExtension = file.filename.split('.').slice(0, -1).join('.');
|
||||||
const parentFolder = file.relative_path.split('/').slice(0, -1).join('/');
|
const parentFolder = file.relative_path.split('/').slice(0, -1).join('/');
|
||||||
const transcriptURL = '/transcript/' + parentFolder + '/Transkription/' + filenameWithoutExtension + '.md';
|
const transcriptURL = '/transcript/' + parentFolder + '/Transkription/' + filenameWithoutExtension + '.md';
|
||||||
|
const isAudio = audioExts.includes((file.filetype || '').toLowerCase());
|
||||||
|
const encodedRelPath = encodeURI(file.relative_path);
|
||||||
|
|
||||||
card.className = 'card';
|
card.className = 'card';
|
||||||
|
const fileAction = isAudio
|
||||||
|
? `<button class="btn btn-light play-audio-btn" onclick="player.loadTrack('${file.relative_path}')" style="width:100%;"><i class="bi bi-volume-up"></i> ${filenameWithoutExtension}</button>`
|
||||||
|
: `<a class="btn btn-light download-btn" href="/media/${encodedRelPath}" download style="width:100%;"><i class="bi bi-download"></i> ${file.filename}</a>`;
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p><button class="btn btn-light" onclick="player.loadTrack('${file.relative_path}')" style="width:100%;"><i class="bi bi-volume-up"></i> ${filenameWithoutExtension}</button></p>
|
<p>${fileAction}</p>
|
||||||
<p><button onclick="window.open('/path/${file.relative_path}', '_self');" class="btn btn-light btn-sm" style="width:100%;"><i class="bi bi-folder"></i> ${parentFolder}</button></p>
|
<p><button class="btn btn-light btn-sm folder-open-btn" data-folder="${parentFolder}" data-file="${file.relative_path}" style="width:100%;"><i class="bi bi-folder"></i> ${parentFolder || 'Ordner'}</button></p>
|
||||||
<p class="card-text">Anzahl Downloads: ${file.hitcount}</p>
|
<p class="card-text">Anzahl Downloads: ${file.hitcount}</p>
|
||||||
${ file.performance_date !== undefined ? `<p class="card-text">Datum: ${file.performance_date}</p>` : ``}
|
${ file.performance_date !== undefined ? `<p class="card-text">Datum: ${file.performance_date}</p>` : ``}
|
||||||
${ file.transcript_hits !== undefined ? `<p class="card-text">Treffer im Transkript: ${file.transcript_hits} <a href="#" class="show-transcript" data-url="${transcriptURL}" data-audio-url="${file.relative_path}" highlight="${file.query}"><i class="bi bi-journal-text"></i></a></p>` : ``}
|
${ file.transcript_hits !== undefined ? `<p class="card-text">Treffer im Transkript: ${file.transcript_hits} <a href="#" class="show-transcript" data-url="${transcriptURL}" data-audio-url="${file.relative_path}" highlight="${file.query}"><i class="bi bi-journal-text"></i></a></p>` : ``}
|
||||||
@ -24,6 +33,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
resultsDiv.appendChild(card);
|
resultsDiv.appendChild(card);
|
||||||
});
|
});
|
||||||
attachEventListeners();
|
attachEventListeners();
|
||||||
|
attachSearchFolderButtons();
|
||||||
} else {
|
} else {
|
||||||
resultsDiv.innerHTML = '<p>No results found.</p>';
|
resultsDiv.innerHTML = '<p>No results found.</p>';
|
||||||
}
|
}
|
||||||
@ -55,6 +65,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore previously selected filetypes (multi-select). Default to audio if none stored.
|
||||||
|
const previousFiletypes = localStorage.getItem("searchFiletypes");
|
||||||
|
if (previousFiletypes) {
|
||||||
|
try {
|
||||||
|
const list = JSON.parse(previousFiletypes);
|
||||||
|
document.querySelectorAll('input[name=\"filetype\"]').forEach(cb => {
|
||||||
|
cb.checked = list.includes(cb.value);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing stored filetypes', e);
|
||||||
|
document.getElementById('filetype-audio').checked = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById('filetype-audio').checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Restore the checkbox state for "Im Transkript suchen"
|
// Restore the checkbox state for "Im Transkript suchen"
|
||||||
const previousIncludeTranscript = localStorage.getItem("searchIncludeTranscript");
|
const previousIncludeTranscript = localStorage.getItem("searchIncludeTranscript");
|
||||||
if (previousIncludeTranscript !== null) {
|
if (previousIncludeTranscript !== null) {
|
||||||
@ -74,6 +100,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const categoryRadio = document.querySelector('input[name="category"]:checked');
|
const categoryRadio = document.querySelector('input[name="category"]:checked');
|
||||||
const category = categoryRadio ? categoryRadio.value : '';
|
const category = categoryRadio ? categoryRadio.value : '';
|
||||||
|
|
||||||
|
// Get selected filetypes (allow multiple). Default to audio if none selected.
|
||||||
|
const filetypeCheckboxes = document.querySelectorAll('input[name=\"filetype\"]');
|
||||||
|
let filetypes = Array.from(filetypeCheckboxes).filter(cb => cb.checked).map(cb => cb.value);
|
||||||
|
if (filetypes.length === 0) {
|
||||||
|
// enforce audio as default when user unchecked all
|
||||||
|
document.getElementById('filetype-audio').checked = true;
|
||||||
|
filetypes = ['audio'];
|
||||||
|
}
|
||||||
|
|
||||||
// Prevent accidental re-selection of already selected radio buttons
|
// Prevent accidental re-selection of already selected radio buttons
|
||||||
const radios = document.querySelectorAll('input[name="category"]');
|
const radios = document.querySelectorAll('input[name="category"]');
|
||||||
radios.forEach(radio => {
|
radios.forEach(radio => {
|
||||||
@ -98,6 +133,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
formData.append('datefrom', document.getElementById('datefrom').value);
|
formData.append('datefrom', document.getElementById('datefrom').value);
|
||||||
formData.append('dateto', document.getElementById('dateto').value);
|
formData.append('dateto', document.getElementById('dateto').value);
|
||||||
formData.append('includeTranscript', includeTranscript);
|
formData.append('includeTranscript', includeTranscript);
|
||||||
|
filetypes.forEach(ft => formData.append('filetype', ft));
|
||||||
|
|
||||||
const settleSpinner = () => {
|
const settleSpinner = () => {
|
||||||
clearTimeout(spinnerTimer);
|
clearTimeout(spinnerTimer);
|
||||||
@ -121,6 +157,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Save the search word, selected category, and checkbox state in localStorage
|
// Save the search word, selected category, and checkbox state in localStorage
|
||||||
localStorage.setItem("searchQuery", query);
|
localStorage.setItem("searchQuery", query);
|
||||||
localStorage.setItem("searchCategory", category);
|
localStorage.setItem("searchCategory", category);
|
||||||
|
localStorage.setItem("searchFiletypes", JSON.stringify(filetypes));
|
||||||
localStorage.setItem("searchIncludeTranscript", includeTranscript);
|
localStorage.setItem("searchIncludeTranscript", includeTranscript);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@ -140,6 +177,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
localStorage.removeItem("searchResponse");
|
localStorage.removeItem("searchResponse");
|
||||||
localStorage.removeItem("searchQuery");
|
localStorage.removeItem("searchQuery");
|
||||||
localStorage.removeItem("searchCategory");
|
localStorage.removeItem("searchCategory");
|
||||||
|
localStorage.removeItem("searchFiletypes");
|
||||||
localStorage.removeItem("folder");
|
localStorage.removeItem("folder");
|
||||||
localStorage.removeItem("datefrom");
|
localStorage.removeItem("datefrom");
|
||||||
localStorage.removeItem("dateto");
|
localStorage.removeItem("dateto");
|
||||||
@ -149,6 +187,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
document.querySelector('input[name="category"][value=""]').checked = true;
|
document.querySelector('input[name="category"][value=""]').checked = true;
|
||||||
const otherRadios = document.querySelectorAll('input[name="category"]:not([value=""])');
|
const otherRadios = document.querySelectorAll('input[name="category"]:not([value=""])');
|
||||||
otherRadios.forEach(radio => radio.checked = false);
|
otherRadios.forEach(radio => radio.checked = false);
|
||||||
|
document.getElementById('filetype-audio').checked = true;
|
||||||
|
const otherFiletypeBoxes = document.querySelectorAll('input[name=\"filetype\"]:not([value=\"audio\"])');
|
||||||
|
otherFiletypeBoxes.forEach(cb => cb.checked = false);
|
||||||
document.getElementById('folder').value = ''; // Reset to "Alle"
|
document.getElementById('folder').value = ''; // Reset to "Alle"
|
||||||
document.getElementById('datefrom').value = ''; // Reset date from
|
document.getElementById('datefrom').value = ''; // Reset date from
|
||||||
document.getElementById('dateto').value = ''; // Reset date to
|
document.getElementById('dateto').value = ''; // Reset date to
|
||||||
@ -164,6 +205,30 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function attachSearchFolderButtons() {
|
||||||
|
document.querySelectorAll('.folder-open-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const folder = btn.dataset.folder;
|
||||||
|
const file = btn.dataset.file;
|
||||||
|
openFolderAndHighlight(folder, file);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFolderAndHighlight(folderPath, filePath) {
|
||||||
|
const targetFolder = folderPath || '';
|
||||||
|
// Switch back to main view before loading folder
|
||||||
|
viewMain();
|
||||||
|
loadDirectory(targetFolder).then(() => {
|
||||||
|
const target = document.querySelector(`.play-file[data-url=\"${filePath}\"]`);
|
||||||
|
if (target) {
|
||||||
|
target.classList.add('search-highlight');
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function syncThemeColor() {
|
function syncThemeColor() {
|
||||||
// read the CSS variable from :root (or any selector)
|
// read the CSS variable from :root (or any selector)
|
||||||
const cssVar = getComputedStyle(document.documentElement)
|
const cssVar = getComputedStyle(document.documentElement)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = 'gottesdienste-app';
|
const CACHE_NAME = 'gottesdienste-app-v1.2';
|
||||||
const ASSETS = [
|
const ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
'/static/app.css',
|
'/static/app.css',
|
||||||
|
|||||||
@ -126,6 +126,32 @@
|
|||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
|
<!-- Dateityp -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Dateityp:</label>
|
||||||
|
<div class="d-flex flex-wrap gap-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-audio" value="audio" checked>
|
||||||
|
<label class="form-check-label" for="filetype-audio">Audio</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-video" value="video">
|
||||||
|
<label class="form-check-label" for="filetype-video">Video</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-image" value="image">
|
||||||
|
<label class="form-check-label" for="filetype-image">Bild</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-other" value="other">
|
||||||
|
<label class="form-check-label" for="filetype-other">Sonstige</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Mehrfachauswahl möglich!</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
<!-- Transkript durchsuchen -->
|
<!-- Transkript durchsuchen -->
|
||||||
<div class="form-check mb-3">
|
<div class="form-check mb-3">
|
||||||
<input type="checkbox" class="form-check-input" id="includeTranscript" name="includeTranscript">
|
<input type="checkbox" class="form-check-input" id="includeTranscript" name="includeTranscript">
|
||||||
@ -161,6 +187,18 @@
|
|||||||
<!-- Global Audio Player in Footer -->
|
<!-- Global Audio Player in Footer -->
|
||||||
<footer>
|
<footer>
|
||||||
<div class="audio-player-container" id="audioPlayerContainer">
|
<div class="audio-player-container" id="audioPlayerContainer">
|
||||||
|
<button type="button" class="minimize-button icon-color" aria-label="Player einklappen" aria-expanded="true">
|
||||||
|
<span class="icon-collapse" aria-hidden="true">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="icon-expand" aria-hidden="true">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<div class="audio-player">
|
<div class="audio-player">
|
||||||
<audio id="globalAudio" prefetch="auto">
|
<audio id="globalAudio" prefetch="auto">
|
||||||
Your browser does not support the audio element.
|
Your browser does not support the audio element.
|
||||||
|
|||||||
@ -7,6 +7,6 @@
|
|||||||
{# page content #}
|
{# page content #}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="alert alert-warning">Du hast keine gültige Freigaben.<br>Bitte Ordner mit einem Freigabelink freischalten.</div>
|
<div class="alert alert-warning">Du hast keine gültige Freigaben.<br>Bitte über einen Freigabelink oder QR-Code freischalten.</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user