analyze file index
This commit is contained in:
parent
b4f47d0cb1
commit
9b191c3df7
4
app.py
4
app.py
@ -24,6 +24,7 @@ import auth
|
||||
import analytics as a
|
||||
import folder_secret_config_editor as fsce
|
||||
import helperfunctions as hf
|
||||
import search_db_analyzer as sdb
|
||||
import fnmatch
|
||||
|
||||
app_config = auth.return_app_config()
|
||||
@ -56,6 +57,9 @@ app.add_url_rule('/searchcommand', view_func=search.searchcommand, methods=['POS
|
||||
app.add_url_rule('/admin/folder_secret_config_editor', view_func=auth.require_admin(fsce.folder_secret_config_editor), methods=['GET', 'POST'])
|
||||
app.add_url_rule('/admin/folder_secret_config_editor/data', view_func=auth.require_admin(auth.load_folder_config))
|
||||
app.add_url_rule('/admin/folder_secret_config_editor/action', view_func=auth.require_admin(fsce.folder_secret_config_action), methods=['POST'])
|
||||
app.add_url_rule('/admin/search_db_analyzer', view_func=auth.require_admin(sdb.search_db_analyzer))
|
||||
app.add_url_rule('/admin/search_db_analyzer/query', view_func=auth.require_admin(sdb.search_db_query), methods=['POST'])
|
||||
app.add_url_rule('/admin/search_db_analyzer/folders', view_func=auth.require_admin(sdb.search_db_folders))
|
||||
|
||||
# Grab the HOST_RULE environment variable
|
||||
host_rule = os.getenv("HOST_RULE", "")
|
||||
|
||||
127
search_db_analyzer.py
Normal file
127
search_db_analyzer.py
Normal file
@ -0,0 +1,127 @@
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from flask import render_template, request, jsonify
|
||||
|
||||
import auth
|
||||
|
||||
APP_CONFIG = auth.return_app_config()
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
SEARCH_DB_PATH = os.path.join(BASE_DIR, "search.db")
|
||||
|
||||
# Open search.db in read-only mode to avoid accidental writes.
|
||||
search_db = sqlite3.connect(f"file:{SEARCH_DB_PATH}?mode=ro", uri=True, check_same_thread=False)
|
||||
search_db.row_factory = sqlite3.Row
|
||||
|
||||
|
||||
def _normalize_folder_path(folder_path: str) -> str:
|
||||
"""Normalize folder paths to a consistent, slash-based format."""
|
||||
return folder_path.replace("\\", "/").strip().strip("/")
|
||||
|
||||
|
||||
def _list_children(parent: str = "") -> List[str]:
|
||||
"""
|
||||
Return the next folder level for the given parent.
|
||||
- parent == "" → first level (basefolder column)
|
||||
- parent != "" → distinct next segment in relative_path below that parent
|
||||
"""
|
||||
cursor = search_db.cursor()
|
||||
|
||||
normalized = _normalize_folder_path(parent)
|
||||
if not normalized:
|
||||
rows = cursor.execute("SELECT DISTINCT basefolder FROM files ORDER BY basefolder").fetchall()
|
||||
return [row["basefolder"] for row in rows if row["basefolder"]]
|
||||
|
||||
prefix = normalized + "/"
|
||||
rows = cursor.execute(
|
||||
"SELECT relative_path FROM files WHERE relative_path LIKE ?",
|
||||
(prefix + "%",)
|
||||
).fetchall()
|
||||
|
||||
children = set()
|
||||
plen = len(prefix)
|
||||
for row in rows:
|
||||
rel = row["relative_path"]
|
||||
# Strip the prefix and keep only the next segment
|
||||
remainder = rel[plen:]
|
||||
if "/" in remainder:
|
||||
next_seg = remainder.split("/", 1)[0]
|
||||
else:
|
||||
# File directly under parent; no deeper folder
|
||||
continue
|
||||
if next_seg:
|
||||
children.add(next_seg)
|
||||
|
||||
return sorted(children)
|
||||
|
||||
|
||||
def _query_counts(folder_path: str) -> Dict[str, Any]:
|
||||
"""Run a grouped count query on search.db filtered by folder_path."""
|
||||
normalized = _normalize_folder_path(folder_path)
|
||||
params: List[str] = []
|
||||
conditions: List[str] = []
|
||||
|
||||
if not normalized:
|
||||
raise ValueError("Bitte einen Ordnerpfad angeben.")
|
||||
|
||||
# Match both basefolder and deeper paths inside that folder.
|
||||
conditions.append("(relative_path LIKE ? OR basefolder = ?)")
|
||||
params.extend([f"{normalized}/%", normalized])
|
||||
|
||||
where_sql = " AND ".join(conditions) if conditions else "1=1"
|
||||
sql = f"""
|
||||
SELECT COALESCE(category, 'Keine Kategorie') AS category_label,
|
||||
COUNT(*) AS file_count
|
||||
FROM files
|
||||
WHERE {where_sql}
|
||||
GROUP BY category_label
|
||||
ORDER BY file_count DESC
|
||||
"""
|
||||
|
||||
cursor = search_db.cursor()
|
||||
rows = cursor.execute(sql, params).fetchall()
|
||||
|
||||
total = sum(row["file_count"] for row in rows)
|
||||
categories = [
|
||||
{"category": row["category_label"], "count": row["file_count"]}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return {"total": total, "categories": categories}
|
||||
|
||||
|
||||
def search_db_analyzer():
|
||||
"""Render the UI for analyzing search.db by folder."""
|
||||
return render_template(
|
||||
"search_db_analyzer.html",
|
||||
admin_enabled=auth.is_admin(),
|
||||
title_short=APP_CONFIG.get("TITLE_SHORT", "Default Title"),
|
||||
title_long=APP_CONFIG.get("TITLE_LONG", "Default Title"),
|
||||
)
|
||||
|
||||
|
||||
def search_db_query():
|
||||
"""Return grouped counts by category for a given folder path."""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
folder_path = (payload.get("folder_path") or "").strip()
|
||||
|
||||
try:
|
||||
result = _query_counts(folder_path)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
return jsonify({"error": f"Abfrage fehlgeschlagen: {exc}"}), 500
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def search_db_folders():
|
||||
"""Return next-level folder names for the given parent path (or basefolders)."""
|
||||
parent = request.args.get("parent", "").strip()
|
||||
try:
|
||||
children = _list_children(parent)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
return jsonify({"error": f"Ordner konnten nicht geladen werden: {exc}"}), 500
|
||||
|
||||
return jsonify({"children": children})
|
||||
@ -42,6 +42,8 @@
|
||||
<a href="{{ url_for('songs_dashboard') }}">Wiederholungen</a>
|
||||
<span> | </span>
|
||||
<a href="{{ url_for('folder_secret_config_editor') }}" id="edit-folder-config">Ordnerkonfiguration</a>
|
||||
<span> | </span>
|
||||
<a href="{{ url_for('search_db_analyzer') }}">Dateiindex Analyse</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
262
templates/search_db_analyzer.html
Normal file
262
templates/search_db_analyzer.html
Normal file
@ -0,0 +1,262 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Search-DB Analyse{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Index auswerten</h2>
|
||||
<p class="text-muted">
|
||||
Ordner auswählen und die Anzahl der Dateien pro Kategorie aus der Dateiindex abrufen.
|
||||
</p>
|
||||
|
||||
<form id="analyzer-form" class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Ordnerauswahl</label>
|
||||
<div id="folder-levels" class="d-flex flex-wrap gap-2 align-items-end"></div>
|
||||
<div class="form-text">Wähle zuerst den Hauptordner, füge dann bei Bedarf Unterordner hinzu.</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-12 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100">Abfrage starten</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="analyzer-feedback" class="mt-3 text-danger" style="display: none;"></div>
|
||||
|
||||
<div id="analyzer-result" class="mt-4" style="display: none;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h4 class="mb-0">Ergebnis</h4>
|
||||
<span class="badge bg-secondary" id="totalCount"></span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kategorie</th>
|
||||
<th>Anzahl Dateien</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="result-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('analyzer-form');
|
||||
const feedback = document.getElementById('analyzer-feedback');
|
||||
const resultWrapper = document.getElementById('analyzer-result');
|
||||
const resultBody = document.getElementById('result-body');
|
||||
const totalCount = document.getElementById('totalCount');
|
||||
const levelsContainer = document.getElementById('folder-levels');
|
||||
|
||||
const levelSelects = [];
|
||||
|
||||
const createSelect = (level, options) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'folder-level';
|
||||
const select = document.createElement('select');
|
||||
select.className = 'form-select';
|
||||
select.dataset.level = level;
|
||||
select.required = level === 0;
|
||||
|
||||
const placeholder = document.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = level === 0 ? 'Bitte auswählen' : 'Optionaler Unterordner';
|
||||
select.appendChild(placeholder);
|
||||
|
||||
options.forEach((opt) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = opt;
|
||||
option.textContent = opt;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
removeLevelsFrom(level + 1);
|
||||
updateControlButtons();
|
||||
});
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'd-flex gap-2 align-items-center';
|
||||
const btnGroup = document.createElement('div');
|
||||
btnGroup.className = 'btn-group btn-group-sm';
|
||||
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.type = 'button';
|
||||
addBtn.className = 'btn btn-outline-secondary';
|
||||
addBtn.textContent = '+';
|
||||
addBtn.addEventListener('click', () => handleAddLevel());
|
||||
btnGroup.appendChild(addBtn);
|
||||
|
||||
let removeBtn = null;
|
||||
if (level > 0) {
|
||||
removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'btn btn-outline-danger';
|
||||
removeBtn.textContent = '-';
|
||||
removeBtn.addEventListener('click', () => {
|
||||
removeLevelsFrom(level);
|
||||
updateControlButtons();
|
||||
});
|
||||
btnGroup.appendChild(removeBtn);
|
||||
}
|
||||
|
||||
controls.append(select, btnGroup);
|
||||
wrapper.append(controls);
|
||||
levelsContainer.appendChild(wrapper);
|
||||
levelSelects.push({ wrapper, select, addBtn, removeBtn, btnGroup });
|
||||
};
|
||||
|
||||
const removeLevelsFrom = (startIndex) => {
|
||||
while (levelSelects.length > startIndex) {
|
||||
const item = levelSelects.pop();
|
||||
levelsContainer.removeChild(item.wrapper);
|
||||
}
|
||||
};
|
||||
|
||||
const currentPath = () => {
|
||||
const parts = levelSelects
|
||||
.map(({ select }) => select.value)
|
||||
.filter((v) => v && v.trim() !== '');
|
||||
return parts.join('/');
|
||||
};
|
||||
|
||||
const fetchChildren = async (parentPath = '') => {
|
||||
const params = parentPath ? `?parent=${encodeURIComponent(parentPath)}` : '';
|
||||
const response = await fetch(`{{ url_for("search_db_folders") }}${params}`);
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Ordner konnten nicht geladen werden.');
|
||||
}
|
||||
return data.children || [];
|
||||
};
|
||||
|
||||
const updateControlButtons = () => {
|
||||
if (!levelSelects.length) {
|
||||
return;
|
||||
}
|
||||
levelSelects.forEach((item, idx) => {
|
||||
const isLast = idx === levelSelects.length - 1;
|
||||
if (item.btnGroup) {
|
||||
item.btnGroup.classList.toggle('d-none', !isLast);
|
||||
}
|
||||
if (item.addBtn) {
|
||||
item.addBtn.disabled = !item.select.value;
|
||||
}
|
||||
if (item.removeBtn) {
|
||||
const hideRemove = !isLast || idx === 0;
|
||||
item.removeBtn.classList.toggle('d-none', hideRemove);
|
||||
item.removeBtn.disabled = hideRemove;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const initBaseLevel = async () => {
|
||||
try {
|
||||
const children = await fetchChildren('');
|
||||
if (!children.length) {
|
||||
feedback.textContent = 'Keine Ordner in der Datenbank gefunden.';
|
||||
feedback.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
createSelect(0, children);
|
||||
updateControlButtons();
|
||||
} catch (error) {
|
||||
feedback.textContent = error.message;
|
||||
feedback.style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddLevel = async () => {
|
||||
feedback.style.display = 'none';
|
||||
const path = currentPath();
|
||||
if (!path) {
|
||||
feedback.textContent = 'Bitte zuerst einen Hauptordner auswählen.';
|
||||
feedback.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const children = await fetchChildren(path);
|
||||
if (!children.length) {
|
||||
feedback.textContent = 'Keine weiteren Unterordner vorhanden.';
|
||||
feedback.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
createSelect(levelSelects.length, children);
|
||||
updateControlButtons();
|
||||
} catch (error) {
|
||||
feedback.textContent = error.message;
|
||||
feedback.style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
initBaseLevel();
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
feedback.style.display = 'none';
|
||||
resultWrapper.style.display = 'none';
|
||||
|
||||
const folderPath = currentPath();
|
||||
|
||||
if (!folderPath) {
|
||||
feedback.textContent = 'Bitte einen Ordner auswählen.';
|
||||
feedback.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Wird geladen...';
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ url_for("search_db_query") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder_path: folderPath })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Abfrage fehlgeschlagen.');
|
||||
}
|
||||
|
||||
resultBody.innerHTML = '';
|
||||
if (data.categories && data.categories.length) {
|
||||
data.categories.forEach((row) => {
|
||||
const tr = document.createElement('tr');
|
||||
const cat = document.createElement('td');
|
||||
const cnt = document.createElement('td');
|
||||
|
||||
cat.textContent = row.category || 'Keine Kategorie';
|
||||
cnt.textContent = row.count;
|
||||
|
||||
tr.append(cat, cnt);
|
||||
resultBody.appendChild(tr);
|
||||
});
|
||||
} else {
|
||||
const tr = document.createElement('tr');
|
||||
const td = document.createElement('td');
|
||||
td.colSpan = 2;
|
||||
td.textContent = 'Keine Treffer gefunden.';
|
||||
tr.appendChild(td);
|
||||
resultBody.appendChild(tr);
|
||||
}
|
||||
|
||||
totalCount.textContent = `${data.total || 0} Dateien`;
|
||||
resultWrapper.style.display = 'block';
|
||||
} catch (error) {
|
||||
feedback.textContent = error.message;
|
||||
feedback.style.display = 'block';
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Abfrage starten';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Loading…
x
Reference in New Issue
Block a user