0.2.0b #1
@@ -53,6 +53,7 @@ Config explanation:
|
|||||||
| `file`-`repos` | `["data/files"]` | List of directories where source files are stored |
|
| `file`-`repos` | `["data/files"]` | List of directories where source files are stored |
|
||||||
| `file`-`dw` | `"data/files"` | Directory for new downloads |
|
| `file`-`dw` | `"data/files"` | Directory for new downloads |
|
||||||
| `file`-`thumb` | `"data/thumb"` | Directory for thumbnails |
|
| `file`-`thumb` | `"data/thumb"` | Directory for thumbnails |
|
||||||
|
| `file`-`data` | `"data"` | Directory for database |
|
||||||
|
|
||||||
1. You must obtain your Gelbooru User ID and Hash and add them to the config file for the system to function correctly.
|
1. You must obtain your Gelbooru User ID and Hash and add them to the config file for the system to function correctly.
|
||||||
2. Install the script in [VioletMonkey](https://git.nekono.su/Nyako/Local-Booru/raw/branch/main/button_script.user.js).
|
2. Install the script in [VioletMonkey](https://git.nekono.su/Nyako/Local-Booru/raw/branch/main/button_script.user.js).
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
| `file`-`repos` | `["data/files"]` | Список каталогов где храняться полные файлы |
|
| `file`-`repos` | `["data/files"]` | Список каталогов где храняться полные файлы |
|
||||||
| `file`-`dw` | `"data/files"` | Каталог хранения загрузок |
|
| `file`-`dw` | `"data/files"` | Каталог хранения загрузок |
|
||||||
| `file`-`thumb` | `"data/thumb"` | Каталог обложек |
|
| `file`-`thumb` | `"data/thumb"` | Каталог обложек |
|
||||||
|
| `file`-`data` | `"data"` | Каталог базы данных |
|
||||||
|
|
||||||
1. Обязательно надо получить ID и Hash пользователя и внести в конфиг для работы!
|
1. Обязательно надо получить ID и Hash пользователя и внести в конфиг для работы!
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
from flask import (Flask, jsonify, render_template, render_template_string,
|
from flask import (Flask, jsonify, render_template, render_template_string,
|
||||||
request, send_from_directory, make_response)
|
request, send_from_directory, make_response, send_file)
|
||||||
|
|
||||||
from system_module import db, gelbooru, image_processor, logger, tasks
|
from system_module import db, gelbooru, image_processor, logger, tasks
|
||||||
|
|
||||||
@@ -26,12 +26,13 @@ GB_HEADERS = CONFIG_ROOT["gb"]["headers"]
|
|||||||
FILE_FOLDERS = CONFIG_ROOT["file"]["repos"]
|
FILE_FOLDERS = CONFIG_ROOT["file"]["repos"]
|
||||||
FILE_DW_FOLDERS = CONFIG_ROOT["file"]["dw"]
|
FILE_DW_FOLDERS = CONFIG_ROOT["file"]["dw"]
|
||||||
THUMB_FOLDER = CONFIG_ROOT["file"]["thumb"]
|
THUMB_FOLDER = CONFIG_ROOT["file"]["thumb"]
|
||||||
|
DATA_FOLDER = CONFIG_ROOT["file"]["data"]
|
||||||
|
|
||||||
|
|
||||||
gb = gelbooru.GB(GB_ID, GB_HASH, GB_HEADERS, FILE_DW_FOLDERS)
|
gb = gelbooru.GB(GB_ID, GB_HASH, GB_HEADERS, FILE_DW_FOLDERS)
|
||||||
log = logger.Logger()
|
log = logger.Logger()
|
||||||
log.init_global_exception_hook()
|
log.init_global_exception_hook()
|
||||||
database = db.DB(True)
|
database = db.DB(DATA_FOLDER)
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
if not image_processor.check_ffmpeg(): print("WARNING!\n\nУ вас не установлен ffmpeg. Генерация превью для видео не доступна!")
|
if not image_processor.check_ffmpeg(): print("WARNING!\n\nУ вас не установлен ffmpeg. Генерация превью для видео не доступна!")
|
||||||
@@ -54,7 +55,7 @@ def post(post_id):
|
|||||||
|
|
||||||
@app.route('/file/<path:filename>')
|
@app.route('/file/<path:filename>')
|
||||||
def serve_image(filename):
|
def serve_image(filename):
|
||||||
folder_path = task.find_file(filename, FILE_FOLDERS)
|
folder_path = tasks.find_file(filename, FILE_FOLDERS)
|
||||||
if folder_path == None:
|
if folder_path == None:
|
||||||
return make_response("Not found", 404)
|
return make_response("Not found", 404)
|
||||||
else:
|
else:
|
||||||
@@ -91,6 +92,8 @@ def api():
|
|||||||
post_id = data["post_id"]
|
post_id = data["post_id"]
|
||||||
result_post = database.get_from_id(post_id)
|
result_post = database.get_from_id(post_id)
|
||||||
return jsonify(result_post)
|
return jsonify(result_post)
|
||||||
|
elif data["type"] == "analytic":
|
||||||
|
return send_file(os.path.join(DATA_FOLDER, "analytic.json"), mimetype="application/json")
|
||||||
elif data["type"] == "task":
|
elif data["type"] == "task":
|
||||||
if data["task"] == "analytic":
|
if data["task"] == "analytic":
|
||||||
if tasks.calculate_analytic(database, CONFIG_ROOT):
|
if tasks.calculate_analytic(database, CONFIG_ROOT):
|
||||||
|
|||||||
+1
-5
@@ -7,11 +7,7 @@ from system_module.gelbooru import gelbooru_date_parse
|
|||||||
|
|
||||||
|
|
||||||
class DB:
|
class DB:
|
||||||
def __init__(self, debug: bool):
|
def __init__(self, db_path: str):
|
||||||
if debug:
|
|
||||||
db_path = "data_test"
|
|
||||||
else:
|
|
||||||
db_path = "data"
|
|
||||||
|
|
||||||
if not os.path.exists(db_path):
|
if not os.path.exists(db_path):
|
||||||
os.makedirs(db_path)
|
os.makedirs(db_path)
|
||||||
|
|||||||
+92
-22
@@ -2,13 +2,13 @@ import os
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
def find_file(filename, file_folders):
|
def find_file(filename: str, file_folders: list[str], is_folder: bool = True):
|
||||||
for folder in file_folders:
|
for folder in file_folders:
|
||||||
folder_path = Path(folder)
|
folder_path = Path(folder)
|
||||||
if not folder_path.exists():
|
if not folder_path.exists():
|
||||||
continue
|
continue
|
||||||
for file_path in folder_path.rglob(filename):
|
for file_path in folder_path.rglob(filename):
|
||||||
return folder_path
|
return str(folder_path) if is_folder else str(file_path)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_size_format(b, factor=1024, suffix="B"):
|
def get_size_format(b, factor=1024, suffix="B"):
|
||||||
@@ -36,9 +36,10 @@ def get_file_list_size(file_list: list[str]):
|
|||||||
|
|
||||||
def get_size_edge(type_searсh: bool, file_list: list[str]):
|
def get_size_edge(type_searсh: bool, file_list: list[str]):
|
||||||
if type_searсh:
|
if type_searсh:
|
||||||
return min(file_list, key=os.path.getsize)
|
result = min(file_list, key=lambda f: Path(f).stat().st_size)
|
||||||
else:
|
else:
|
||||||
return max(file_list, key=os.path.getsize)
|
result = max(file_list, key=lambda f: Path(f).stat().st_size)
|
||||||
|
return str(result)
|
||||||
|
|
||||||
def calculate_analytic(database, config):
|
def calculate_analytic(database, config):
|
||||||
all_db = database.get_all()
|
all_db = database.get_all()
|
||||||
@@ -50,8 +51,9 @@ def calculate_analytic(database, config):
|
|||||||
type_list_file_path = {"images":[], "videos":[], "gifs":[]}
|
type_list_file_path = {"images":[], "videos":[], "gifs":[]}
|
||||||
files_not_found = 0
|
files_not_found = 0
|
||||||
for one_post in all_db:
|
for one_post in all_db:
|
||||||
file_path = find_file(one_post["local_filename"], config["file"]["repos"])
|
file_path = find_file(one_post["local_filename"], config["file"]["repos"], is_folder=False)
|
||||||
if file_path != None:
|
if file_path != None:
|
||||||
|
file_path = str(file_path)
|
||||||
rating_list_file_path[one_post["rating"]].append(file_path)
|
rating_list_file_path[one_post["rating"]].append(file_path)
|
||||||
if one_post["is_gif"] == 1:
|
if one_post["is_gif"] == 1:
|
||||||
type_list_file_path["gifs"].append(file_path)
|
type_list_file_path["gifs"].append(file_path)
|
||||||
@@ -88,11 +90,78 @@ def calculate_analytic(database, config):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
total_file_data_size = file_size_dict["general"]+file_size_dict["questionable"]+file_size_dict["sensitive"]+file_size_dict["explicit"]
|
total_file_data_size = file_size_dict["general"]+file_size_dict["questionable"]+file_size_dict["sensitive"]+file_size_dict["explicit"]
|
||||||
total_thumb_size = get_dir_size(config[["file"]["thumb"]])
|
total_thumb_size = get_dir_size(config["file"]["thumb"])
|
||||||
total_log_size = get_file_list_size(["logs.log"])
|
total_log_size = get_file_list_size(["logs.log"])
|
||||||
total_db_size = get_file_list_size([os.path.join("data", "db.sqlite")])
|
total_db_size = get_file_list_size([os.path.join(config["file"]["data"], "db.sqlite")])
|
||||||
weight_stats_smallest = get_size_edge(True, type_list_file_path["gifs"]+type_list_file_path["images"]+type_list_file_path["videos"])
|
weight_stats_smallest_file = get_size_edge(True, type_list_file_path["gifs"]+type_list_file_path["images"]+type_list_file_path["videos"])
|
||||||
weight_stats_largest = get_size_edge(False, type_list_file_path["gifs"]+type_list_file_path["images"]+type_list_file_path["videos"])
|
weight_stats_largest_file = get_size_edge(False, type_list_file_path["gifs"]+type_list_file_path["images"]+type_list_file_path["videos"])
|
||||||
|
weight_stats_data = {
|
||||||
|
"smallest":{
|
||||||
|
"file":"",
|
||||||
|
"md5":"",
|
||||||
|
"size":0
|
||||||
|
},
|
||||||
|
"largest":{
|
||||||
|
"file":"",
|
||||||
|
"md5":"",
|
||||||
|
"size":0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolution_stats_data = {
|
||||||
|
"smallest":{
|
||||||
|
"file":"",
|
||||||
|
"md5":"",
|
||||||
|
"res":{
|
||||||
|
"width":0,
|
||||||
|
"height":0,
|
||||||
|
"total":99999999999999999999999999
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"largest":{
|
||||||
|
"file":"",
|
||||||
|
"md5":"",
|
||||||
|
"res":{
|
||||||
|
"width":0,
|
||||||
|
"height":0,
|
||||||
|
"total":0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for one_post_scan in all_db:
|
||||||
|
if one_post_scan["local_filename"] == os.path.basename(weight_stats_smallest_file):
|
||||||
|
weight_stats_data["smallest"] = {
|
||||||
|
"file":os.path.basename(weight_stats_smallest_file),
|
||||||
|
"md5":one_post_scan["md5"],
|
||||||
|
"size":get_file_list_size([weight_stats_smallest_file])
|
||||||
|
}
|
||||||
|
if one_post_scan["local_filename"] == os.path.basename(weight_stats_largest_file):
|
||||||
|
weight_stats_data["largest"] = {
|
||||||
|
"file":os.path.basename(weight_stats_largest_file),
|
||||||
|
"md5":one_post_scan["md5"],
|
||||||
|
"size":get_file_list_size([weight_stats_largest_file])
|
||||||
|
}
|
||||||
|
print(get_file_list_size([os.path.basename(weight_stats_largest_file)]))
|
||||||
|
one_post_res_total = one_post_scan["width"]*one_post_scan["height"]
|
||||||
|
if resolution_stats_data["largest"]["res"]["total"] < one_post_res_total:
|
||||||
|
resolution_stats_data["largest"] = {
|
||||||
|
"file":one_post_scan["local_filename"],
|
||||||
|
"md5":one_post_scan["md5"],
|
||||||
|
"res":{
|
||||||
|
"width":one_post_scan["width"],
|
||||||
|
"height":one_post_scan["height"],
|
||||||
|
"total":one_post_res_total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resolution_stats_data["smallest"]["res"]["total"] > one_post_res_total:
|
||||||
|
resolution_stats_data["smallest"] = {
|
||||||
|
"file":one_post_scan["local_filename"],
|
||||||
|
"md5":one_post_scan["md5"],
|
||||||
|
"res":{
|
||||||
|
"width":one_post_scan["width"],
|
||||||
|
"height":one_post_scan["height"],
|
||||||
|
"total":one_post_res_total
|
||||||
|
}
|
||||||
|
}
|
||||||
result = {
|
result = {
|
||||||
"total_images_analyzed": len(all_db),
|
"total_images_analyzed": len(all_db),
|
||||||
"files_not_found":files_not_found,
|
"files_not_found":files_not_found,
|
||||||
@@ -117,29 +186,30 @@ def calculate_analytic(database, config):
|
|||||||
"ratings_stats": ratings_stats,
|
"ratings_stats": ratings_stats,
|
||||||
"resolution_stats": {
|
"resolution_stats": {
|
||||||
"largest": {
|
"largest": {
|
||||||
"filename": max_res["filename"],
|
"filename": resolution_stats_data["largest"]["file"],
|
||||||
"md5": max_res["md5"],
|
"md5": resolution_stats_data["largest"]["md5"],
|
||||||
"resolution": f'{max_res["width"]}x{max_res["height"]}'
|
"resolution": str(resolution_stats_data["largest"]["res"]["width"]) + " x " + str(resolution_stats_data["largest"]["res"]["height"])
|
||||||
},
|
},
|
||||||
"smallest": {
|
"smallest": {
|
||||||
"filename": min_res["filename"],
|
"filename": resolution_stats_data["smallest"]["file"],
|
||||||
"md5": min_res["md5"],
|
"md5": resolution_stats_data["smallest"]["md5"],
|
||||||
"resolution": f'{min_res["width"]}x{min_res["height"]}'
|
"resolution": str(resolution_stats_data["smallest"]["res"]["width"]) + " x " + str(resolution_stats_data["smallest"]["res"]["height"])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"weight_stats": {
|
"weight_stats": {
|
||||||
"largest": {
|
"largest": {
|
||||||
"filename": weight_stats_largest,
|
"filename": os.path.basename(weight_stats_data["largest"]["file"]),
|
||||||
"md5": max_weight["md5"],
|
"md5": weight_stats_data["largest"]["md5"],
|
||||||
"size_formatted": get_size_format(max_weight["size"])
|
"size_formatted": get_size_format(weight_stats_data["largest"]["size"])
|
||||||
},
|
},
|
||||||
"smallest": {
|
"smallest": {
|
||||||
"filename": weight_stats_smallest,
|
"filename": os.path.basename(weight_stats_data["smallest"]["file"]),
|
||||||
"md5": min_weight["md5"],
|
"md5": weight_stats_data["smallest"]["md5"],
|
||||||
"size_formatted": get_size_format(min_weight["size"])
|
"size_formatted": get_size_format(weight_stats_data["smallest"]["size"])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"top_tag":database.get_top_tag("")
|
"top_tag":database.get_top_tag("")
|
||||||
}
|
}
|
||||||
with open(os.path.join(config["file"]["data"], "analytic.json"), 'w', encoding='utf-8') as out_f:
|
with open(os.path.join(config["file"]["data"], "analytic.json"), 'w', encoding='utf-8') as out_f:
|
||||||
json.dump(result, out_f, indent=4, ensure_ascii=False)
|
json.dump(result, out_f, indent=4, ensure_ascii=False)
|
||||||
|
return True
|
||||||
+92
-99
@@ -41,22 +41,21 @@
|
|||||||
.ext-block a { color: var(--link-color); text-decoration: none; word-break: break-all; }
|
.ext-block a { color: var(--link-color); text-decoration: none; word-break: break-all; }
|
||||||
.ext-block a:hover { text-decoration: underline; }
|
.ext-block a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.loader { color: var(--text-muted); font-size: 16px; }
|
||||||
|
|
||||||
@media (max-width: 1000px) { .dashboard-grid { grid-template-columns: 1fr 1fr; } .left-col { grid-column: 1/-1; display: grid; grid-template-columns: 1fr 1fr; } }
|
@media (max-width: 1000px) { .dashboard-grid { grid-template-columns: 1fr 1fr; } .left-col { grid-column: 1/-1; display: grid; grid-template-columns: 1fr 1fr; } }
|
||||||
@media (max-width: 700px) { .dashboard-grid, .left-col { grid-template-columns: 1fr; } header { flex-wrap: wrap; } .search-wrapper { order: 3; min-width: 100%; margin-top: 10px;} }
|
@media (max-width: 700px) { .dashboard-grid, .left-col { grid-template-columns: 1fr; } header { flex-wrap: wrap; } .search-wrapper { order: 3; min-width: 100%; margin-top: 10px;} }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<a href="index.html" class="logo">LocalBooru</a>
|
<a href="/" class="logo">LocalBooru</a>
|
||||||
<a href="analytics.html" class="analytics-link">📊 Analytics</a>
|
|
||||||
<div class="search-wrapper">
|
|
||||||
<input type="text" id="searchInput" placeholder="Search tags...">
|
|
||||||
<ul id="autocompleteList" class="autocomplete-list"></ul>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<h1>Database Analytics</h1>
|
<h1>Database Analytics</h1>
|
||||||
|
<div id="loadingIndicator" class="loader">Fetching data from API...</div>
|
||||||
|
|
||||||
<div class="dashboard-grid" id="analyticsGrid" style="display: none;">
|
<div class="dashboard-grid" id="analyticsGrid" style="display: none;">
|
||||||
<div class="left-col">
|
<div class="left-col">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -68,7 +67,7 @@
|
|||||||
<div class="data-list" id="storageContainer"></div>
|
<div class="data-list" id="storageContainer"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">🎨 Media Types (in img/)</div>
|
<div class="panel-header">🎨 Media Types</div>
|
||||||
<div class="data-list" id="mediaContainer"></div>
|
<div class="data-list" id="mediaContainer"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -78,125 +77,119 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">🏷️ All Tags List</div>
|
<div class="panel-header">🏷️ Top Tags List</div>
|
||||||
<div class="table-header"><span>Tag</span><span>Count</span></div>
|
<div class="table-header"><span>Tag</span><span>Count</span></div>
|
||||||
<div class="data-list" id="tagsListContainer"></div>
|
<div class="data-list" id="tagsListContainer"></div>
|
||||||
<div class="pagination"><button class="page-btn" onclick="prevTagPage()">‹</button><span id="tagPageInfo" style="font-weight:bold; padding:0 10px;"></span><button class="page-btn" onclick="nextTagPage()">›</button></div>
|
<div class="pagination">
|
||||||
|
<button class="page-btn" onclick="prevTagPage()">‹</button>
|
||||||
|
<span id="tagPageInfo" style="font-weight:bold; padding:0 10px;"></span>
|
||||||
|
<button class="page-btn" onclick="nextTagPage()">›</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Заменено, так как в новом JSON нет top_pairs -->
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">🔗 Top 30 Tag Pairs</div>
|
<div class="panel-header">ℹ️ System Overview</div>
|
||||||
<div class="table-header"><span>Pair</span><span>Count</span></div>
|
<div class="data-list" id="overviewContainer"></div>
|
||||||
<div class="data-list" id="pairsContainer"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const searchInput = document.getElementById('searchInput');
|
|
||||||
const list = document.getElementById('autocompleteList');
|
|
||||||
let debounceTimer;
|
let debounceTimer;
|
||||||
|
|
||||||
searchInput.addEventListener('input', () => {
|
|
||||||
clearTimeout(debounceTimer);
|
|
||||||
const words = searchInput.value.split(' ');
|
|
||||||
const currentWord = words[words.length - 1];
|
|
||||||
if (currentWord.length < 2) { list.style.display = 'none'; return; }
|
|
||||||
|
|
||||||
debounceTimer = setTimeout(async () => {
|
|
||||||
const req = { type: "autocomplete", q: currentWord };
|
|
||||||
const data = await mockAutocompleteAPI(req);
|
|
||||||
|
|
||||||
if (data.length === 0) { list.style.display = 'none'; return; }
|
|
||||||
|
|
||||||
list.innerHTML = data.map(item => `<li class="autocomplete-item" data-tag="${item.tag}"><span>${item.tag}</span><span class="autocomplete-count">${item.count}</span></li>`).join('');
|
|
||||||
list.style.display = 'block';
|
|
||||||
|
|
||||||
list.querySelectorAll('.autocomplete-item').forEach(li => {
|
|
||||||
li.addEventListener('click', () => {
|
|
||||||
words[words.length - 1] = li.getAttribute('data-tag');
|
|
||||||
searchInput.value = words.join(' ') + ' ';
|
|
||||||
list.style.display = 'none';
|
|
||||||
searchInput.focus();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, 200);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click', (e) => { if (e.target !== searchInput && e.target !== list) list.style.display = 'none'; });
|
|
||||||
searchInput.addEventListener('keypress', (e) => { if(e.key === 'Enter') window.location.href = `index.html?query=${searchInput.value.trim()}`; });
|
|
||||||
|
|
||||||
let allTags = [], currentTagPage = 1, tagsPerPage = 22;
|
let allTags = [], currentTagPage = 1, tagsPerPage = 22;
|
||||||
|
|
||||||
|
// Укорачиваем длинные имена файлов (хэши) для красивого отображения
|
||||||
|
const shortenName = (name) => name.length > 20 ? name.substring(0, 10) + '...' + name.substring(name.length - 6) : name;
|
||||||
|
|
||||||
async function fetchAnalytics() {
|
async function fetchAnalytics() {
|
||||||
const data = await mockAnalyticsAPI({ type: "analytics" });
|
try {
|
||||||
|
const response = await fetch('/api', {
|
||||||
document.getElementById('ratingContainer').innerHTML = Object.keys(data.ratings).map(k => `
|
method: 'POST',
|
||||||
<div class="rating-box" style="border-top: 2px solid var(--color-${k[0]})">
|
headers: { 'Content-Type': 'application/json' },
|
||||||
<div style="font-weight:bold; color:var(--color-${k[0]}); margin-bottom:5px;">${k.charAt(0).toUpperCase() + k.slice(1)}</div>
|
body: JSON.stringify({ type: "analytic" })
|
||||||
<div style="color:var(--text-muted); margin-bottom:3px;">${data.ratings[k].count} items</div>
|
});
|
||||||
<div style="font-weight:bold;">${data.ratings[k].size}</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
const s = data.storage;
|
if (!response.ok) throw new Error("Network response was not ok");
|
||||||
document.getElementById('storageContainer').innerHTML = `
|
|
||||||
<div class="data-row"><span>Logs:</span><span class="val">${s.logs}</span></div>
|
const data = await response.json();
|
||||||
<div class="data-row"><span>DB:</span><span class="val">${s.db}</span></div>
|
|
||||||
<div class="data-row"><span>Thumb:</span><span class="val">${s.thumb}</span></div>
|
// Скрываем лоадер, показываем сетку
|
||||||
<div class="data-row"><span>Img:</span><span class="val">${s.main}</span></div>
|
document.getElementById('loadingIndicator').style.display = 'none';
|
||||||
<div class="data-row" style="color:var(--color-g); font-weight:bold; border-top:1px solid #444; border-bottom:none; padding-top:8px;"><span>GRAND TOTAL:</span><span>${s.total}</span></div>
|
document.getElementById('analyticsGrid').style.display = 'grid';
|
||||||
`;
|
|
||||||
|
|
||||||
const m = data.media;
|
// 1. Рейтинги
|
||||||
document.getElementById('mediaContainer').innerHTML = `
|
const rStats = data.ratings_stats;
|
||||||
<div class="data-row"><span>IMG:</span><span class="val">${m.img_size}</span></div>
|
document.getElementById('ratingContainer').innerHTML = Object.keys(rStats).map(k => `
|
||||||
<div class="data-row"><span>VID:</span><span class="val">${m.vid_size}</span></div>
|
<div class="rating-box" style="border-top: 2px solid var(--color-${k[0]})">
|
||||||
<div class="data-row"><span>GIF:</span><span class="val">${m.gif_size}</span></div>
|
<div style="font-weight:bold; color:var(--color-${k[0]}); margin-bottom:5px;">${k.charAt(0).toUpperCase() + k.slice(1)}</div>
|
||||||
<div style="font-size:11px; color:var(--text-muted); margin-top:5px;">Total: <b>${m.total_items}</b> | Avg: <b>${m.avg_weight}</b></div>
|
<div style="color:var(--text-muted); margin-bottom:3px;">${rStats[k].count} items</div>
|
||||||
`;
|
<div style="font-weight:bold;">${rStats[k].size_formatted}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
const e = data.extremes;
|
// 2. Хранилище (Storage)
|
||||||
document.getElementById('extremesContainer').innerHTML = `
|
const s = data.storage_stats.folders;
|
||||||
<div class="ext-block"><b>Largest:</b> ${e.largest.val}<br>(<a href="post.html?id=${e.largest.id}">${e.largest.file}</a>)</div>
|
document.getElementById('storageContainer').innerHTML = `
|
||||||
<div class="ext-block"><b>Smallest:</b> ${e.smallest.val}<br>(<a href="post.html?id=${e.smallest.id}">${e.smallest.file}</a>)</div>
|
<div class="data-row"><span>Logs:</span><span class="val">${s.logs}</span></div>
|
||||||
<div class="ext-block"><b>Heaviest:</b> ${e.heaviest.val}<br>(<a href="post.html?id=${e.heaviest.id}">${e.heaviest.file}</a>)</div>
|
<div class="data-row"><span>DB:</span><span class="val">${s.db}</span></div>
|
||||||
<div class="ext-block"><b>Lightest:</b> ${e.lightest.val}<br>(<a href="post.html?id=${e.lightest.id}">${e.lightest.file}</a>)</div>
|
<div class="data-row"><span>Thumb:</span><span class="val">${s.thumb}</span></div>
|
||||||
`;
|
<div class="data-row"><span>Data (Img):</span><span class="val">${s.data}</span></div>
|
||||||
|
<div class="data-row" style="color:var(--color-g); font-weight:bold; border-top:1px solid #444; border-bottom:none; padding-top:8px;"><span>GRAND TOTAL:</span><span>${data.storage_stats.grand_total}</span></div>
|
||||||
|
`;
|
||||||
|
|
||||||
document.getElementById('pairsContainer').innerHTML = data.top_pairs.map(p => `<div class="tag-row"><a href="index.html?query=${p.pair.replace(/ \+ /g, '+')}">${p.pair}</a> <span>${p.count}</span></div>`).join('');
|
// 3. Медиа типы
|
||||||
|
const m = data.storage_stats.types;
|
||||||
|
document.getElementById('mediaContainer').innerHTML = `
|
||||||
|
<div class="data-row"><span>IMG:</span><span class="val">${m.images}</span></div>
|
||||||
|
<div class="data-row"><span>VID:</span><span class="val">${m.videos}</span></div>
|
||||||
|
<div class="data-row"><span>GIF:</span><span class="val">${m.gifs}</span></div>
|
||||||
|
<div style="font-size:11px; color:var(--text-muted); margin-top:5px;">Total: <b>${data.total_images_analyzed}</b> | Avg: <b>${data.average_weight.formatted}</b></div>
|
||||||
|
`;
|
||||||
|
|
||||||
allTags = data.all_tags; renderTagsPage();
|
// 4. Рекорды (Extremes)
|
||||||
document.getElementById('analyticsGrid').style.display = 'grid';
|
const res = data.resolution_stats;
|
||||||
|
const weight = data.weight_stats;
|
||||||
|
document.getElementById('extremesContainer').innerHTML = `
|
||||||
|
<div class="ext-block"><b>Largest Res:</b> ${res.largest.resolution}<br>(<a href="post/${res.largest.md5}">${shortenName(res.largest.filename)}</a>)</div>
|
||||||
|
<div class="ext-block"><b>Smallest Res:</b> ${res.smallest.resolution}<br>(<a href="post/${res.smallest.md5}">${shortenName(res.smallest.filename)}</a>)</div>
|
||||||
|
<div class="ext-block"><b>Heaviest:</b> ${weight.largest.size_formatted}<br>(<a href="post/${weight.largest.md5}">${shortenName(weight.largest.filename)}</a>)</div>
|
||||||
|
<div class="ext-block"><b>Lightest:</b> ${weight.smallest.size_formatted}<br>(<a href="post/${weight.smallest.md5}">${shortenName(weight.smallest.filename)}</a>)</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 5. System Overview (вместо Top Pairs)
|
||||||
|
document.getElementById('overviewContainer').innerHTML = `
|
||||||
|
<div class="data-row"><span>Images Analyzed:</span><span class="val" style="font-weight:bold;">${data.total_images_analyzed}</span></div>
|
||||||
|
<div class="data-row"><span>Files Not Found:</span><span class="val" style="font-weight:bold; color: ${data.files_not_found > 0 ? 'var(--color-e)' : 'inherit'};">${data.files_not_found}</span></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 6. Теги
|
||||||
|
allTags = data.top_tag;
|
||||||
|
renderTagsPage();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching analytics:', error);
|
||||||
|
document.getElementById('loadingIndicator').innerHTML = `<span style="color: var(--color-e)">Failed to load data. See console for details.</span>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Пагинация тегов
|
||||||
function renderTagsPage() {
|
function renderTagsPage() {
|
||||||
document.getElementById('tagPageInfo').innerText = `${currentTagPage} / ${Math.ceil(allTags.length / tagsPerPage)}`;
|
if (!allTags || allTags.length === 0) return;
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(allTags.length / tagsPerPage) || 1;
|
||||||
|
document.getElementById('tagPageInfo').innerText = `${currentTagPage} / ${totalPages}`;
|
||||||
|
|
||||||
const start = (currentTagPage - 1) * tagsPerPage;
|
const start = (currentTagPage - 1) * tagsPerPage;
|
||||||
document.getElementById('tagsListContainer').innerHTML = allTags.slice(start, start + tagsPerPage).map(t => `<div class="tag-row"><a href="index.html?query=${t.name}">${t.name}</a> <span>${t.count}</span></div>`).join('');
|
document.getElementById('tagsListContainer').innerHTML = allTags.slice(start, start + tagsPerPage).map(t =>
|
||||||
|
`<div class="tag-row"><a href="index.html?query=${encodeURIComponent(t.tag)}">${t.tag}</a> <span>${t.count}</span></div>`
|
||||||
|
).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
window.prevTagPage = () => { if(currentTagPage > 1) { currentTagPage--; renderTagsPage(); }};
|
window.prevTagPage = () => { if(currentTagPage > 1) { currentTagPage--; renderTagsPage(); }};
|
||||||
window.nextTagPage = () => { if(currentTagPage < Math.ceil(allTags.length/tagsPerPage)) { currentTagPage++; renderTagsPage(); }};
|
window.nextTagPage = () => { if(currentTagPage < Math.ceil(allTags.length/tagsPerPage)) { currentTagPage++; renderTagsPage(); }};
|
||||||
|
|
||||||
// --- ЗАГЛУШКИ ---
|
// Запуск
|
||||||
function mockAutocompleteAPI(req) { return new Promise(res => { const db = [{tag: "long_hair", count: 8184}, {tag: "1girl", count: 11061}]; res(db.filter(t => t.tag.includes(req.q.toLowerCase()))); }); }
|
|
||||||
|
|
||||||
function mockAnalyticsAPI() {
|
|
||||||
return new Promise(res => { setTimeout(() => { res({
|
|
||||||
ratings: { explicit: { count: 1605, size: "4.52 GB" }, sensitive: { count: 6623, size: "19.35 GB" }, questionable: { count: 1591, size: "4.72 GB" }, general: { count: 4184, size: "10.29 GB" } },
|
|
||||||
storage: { logs: "14.44 MB", db: "24.39 MB", thumb: "331.45 MB", main: "38.88 GB", total: "39.24 GB" },
|
|
||||||
media: { img_size: "38.56 GB", vid_size: "140.80 MB", gif_size: "181.30 MB", total_items: 14003, avg_weight: "2.84 MB" },
|
|
||||||
extremes: {
|
|
||||||
largest: { val: "9921x14031", id: "5a63c", file: "5a63c28906dc7e0e...png" },
|
|
||||||
smallest: { val: "240x280", id: "8fc61", file: "8fc614e712ae2ad...gif" },
|
|
||||||
heaviest: { val: "65.21 MB", id: "4ab6a", file: "4ab6acf0a32c61...png" },
|
|
||||||
lightest: { val: "6.49 KB", id: "28c67", file: "28c679ea3483e1...png" }
|
|
||||||
},
|
|
||||||
all_tags: Array.from({length: 100}, (_,i) => ({name:`tag_${i}`, count:100-i})),
|
|
||||||
top_pairs: [{ pair: "1girl + highres", count: 10236 }, { pair: "1girl + solo", count: 8902 }]
|
|
||||||
}); }, 300);});
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchAnalytics();
|
fetchAnalytics();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -64,9 +64,7 @@
|
|||||||
|
|
||||||
<header>
|
<header>
|
||||||
<a href="/" class="logo">LocalBooru</a>
|
<a href="/" class="logo">LocalBooru</a>
|
||||||
<!--
|
|
||||||
<a class="analytics" href="/analytics">📊 Analytics</a>
|
<a class="analytics" href="/analytics">📊 Analytics</a>
|
||||||
-->
|
|
||||||
<a class="tasks" href="/task">⚙️ Tasks</a>
|
<a class="tasks" href="/task">⚙️ Tasks</a>
|
||||||
<div class="search-wrapper">
|
<div class="search-wrapper">
|
||||||
<input type="text" id="searchInput" placeholder="Search tags...">
|
<input type="text" id="searchInput" placeholder="Search tags...">
|
||||||
|
|||||||
@@ -56,10 +56,6 @@
|
|||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<a href="/" class="logo">LocalBooru</a>
|
<a href="/" class="logo">LocalBooru</a>
|
||||||
<div class="search-wrapper">
|
|
||||||
<input type="text" id="searchInput" placeholder="Search tags...">
|
|
||||||
<ul id="autocompleteList" class="autocomplete-list"></ul>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
@@ -139,44 +135,6 @@
|
|||||||
btn.innerText = "Запустить";
|
btn.innerText = "Запустить";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// === ЛОГИКА ПОИСКА (оставлена из предыдущей версии) ===
|
|
||||||
const searchInput = document.getElementById('searchInput');
|
|
||||||
const list = document.getElementById('autocompleteList');
|
|
||||||
let debounceTimer;
|
|
||||||
|
|
||||||
searchInput.addEventListener('input', () => {
|
|
||||||
clearTimeout(debounceTimer);
|
|
||||||
const words = searchInput.value.split(' ');
|
|
||||||
const currentWord = words[words.length - 1];
|
|
||||||
if (currentWord.length < 2) { list.style.display = 'none'; return; }
|
|
||||||
|
|
||||||
debounceTimer = setTimeout(async () => {
|
|
||||||
const req = { type: "autocomplete", q: currentWord };
|
|
||||||
const data = await mockAutocompleteAPI(req);
|
|
||||||
|
|
||||||
if (data.length === 0) { list.style.display = 'none'; return; }
|
|
||||||
|
|
||||||
list.innerHTML = data.map(item => `<li class="autocomplete-item" data-tag="${item.tag}"><span>${item.tag}</span><span class="autocomplete-count">${item.count}</span></li>`).join('');
|
|
||||||
list.style.display = 'block';
|
|
||||||
|
|
||||||
list.querySelectorAll('.autocomplete-item').forEach(li => {
|
|
||||||
li.addEventListener('click', () => {
|
|
||||||
words[words.length - 1] = li.getAttribute('data-tag');
|
|
||||||
searchInput.value = words.join(' ') + ' ';
|
|
||||||
list.style.display = 'none';
|
|
||||||
searchInput.focus();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, 200);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click', (e) => { if (e.target !== searchInput && e.target !== list) list.style.display = 'none'; });
|
|
||||||
searchInput.addEventListener('keypress', (e) => { if(e.key === 'Enter') window.location.href = `index.html?query=${searchInput.value.trim()}`; });
|
|
||||||
|
|
||||||
// Заглушка для поиска (оставьте или замените на ваш fetch)
|
|
||||||
function mockAutocompleteAPI(req) { return new Promise(res => { const db = [{tag: "long_hair", count: 8184}, {tag: "1girl", count: 11061}]; res(db.filter(t => t.tag.includes(req.q.toLowerCase()))); }); }
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user