diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9fdcf8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +cache/ +__pycache__/ diff --git a/__pycache__/mediascan.cpython-313.pyc b/__pycache__/mediascan.cpython-313.pyc deleted file mode 100644 index 07ac527..0000000 --- a/__pycache__/mediascan.cpython-313.pyc +++ /dev/null Binary files differ diff --git a/app.py b/app.py index 3a332d6..9f0780a 100644 --- a/app.py +++ b/app.py @@ -1,44 +1,55 @@ from flask import Flask, render_template, request, jsonify import os import json -from mediascan import list_media_files, filter_media_files, human_readable_size, get_media_info_with_ffprobe +from mediascan import load_config, save_config, list_media_files, filter_media_files, human_readable_size, get_media_info_with_ffprobe, media_get_from_cache, media_create_cache, media_remove_cache, get_single_media_by_path +from urllib.parse import unquote app = Flask(__name__) # Load configuration CONFIG_FILE = 'config.json' -def load_config(): - if os.path.exists(CONFIG_FILE): - with open(CONFIG_FILE, 'r') as f: - return json.load(f) - else: - return {"directories": []} - -def save_config(config): - with open(CONFIG_FILE, 'w') as f: - json.dump(config, f, indent=4) - -config = load_config() +config = load_config(CONFIG_FILE) @app.route('/') def index(): return render_template('index.html') +@app.route("/single") +def single_media(): + # Получаем путь из строки запроса + path = request.args.get("path", "") # Значение параметра "path" + if not path: + return "Path not provided", 400 + + # Декодируем путь + path_to_mediafile = unquote(path) + media_file = get_single_media_by_path(path_to_mediafile) + # return jsonify(media_file) + return render_template('single.html', file=media_file) + +@app.route('/media-list/clear-cache') +def media_list_clear_cache(): + return jsonify({"status": media_remove_cache(config["cache_dir"])}) + @app.route('/media-list', methods=['GET']) def media_list(): - media_files = list_media_files(config.get("directories", [])) - allowed_formats = config.get("allowed_formats", []) - filtered_files = filter_media_files(media_files, allowed_formats) - - # Add detailed media info - for file in filtered_files: - file["size"], file["size_unit"] = human_readable_size(file["size"]) - file_info = get_media_info_with_ffprobe(file["path"]) - if file_info: - file["info"] = file_info - else: - file["info"] = {"error": "Failed to retrieve media info"} + filtered_files = media_get_from_cache(config["cache_dir"]) + if filtered_files == None: + media_files = list_media_files(config.get("directories", [])) + allowed_formats = config.get("allowed_formats", []) + filtered_files = filter_media_files(media_files, allowed_formats) + + # Add detailed media info + for file in filtered_files: + file["size"], file["size_unit"], file["size_bytes"] = human_readable_size(file["size"]) + file_info = get_media_info_with_ffprobe(file["path"]) + if file_info: + file["info"] = file_info + else: + file["info"] = {"error": "Failed to retrieve media info"} + + media_create_cache(config["cache_dir"], filtered_files) return jsonify(filtered_files) @app.route('/configure', methods=['GET', 'POST']) @@ -48,7 +59,7 @@ allowed_formats = request.json.get('allowed_formats', []) config['directories'] = directories config['allowed_formats'] = allowed_formats - save_config(config) + save_config(CONFIG_FILE, config) return jsonify({"status": "success"}) return jsonify(config) diff --git a/config.json b/config.json index d8fde95..2c2fb59 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,7 @@ { "directories": [ - "/home/gbook/media-storage/secraid5_storage/films" + "/home/gmikcon/media-storage/NetTera1" ], - "allowed_formats": [".mp4", ".m4p", ".m4v", ".mkv", ".webm", ".flv", ".vob", ".ogv", ".amv", ".mng", ".avi", ".mov", ".viv"] + "allowed_formats": [".mp4", ".m4p", ".m4v", ".mkv", ".webm", ".flv", ".vob", ".ogv", ".amv", ".mng", ".avi", ".mov", ".viv"], + "cache_dir": "cache" } \ No newline at end of file diff --git a/mediascan.py b/mediascan.py index 1198729..b3bc0dc 100644 --- a/mediascan.py +++ b/mediascan.py @@ -3,6 +3,40 @@ import ffmpeg import subprocess +def load_config(config_file): + if os.path.exists(config_file): + with open(config_file, 'r') as f: + return json.load(f) + else: + return {"directories": []} + +def save_config(config_file, config): + with open(config_file, 'w') as f: + json.dump(config, f, indent=4) + +def media_create_cache(cache_dir, data): + filepath = f"{cache_dir}/mediascan.json" + with open(filepath, 'w') as f: + json.dump(data, f, indent=2) + +def media_remove_cache(cache_dir): + filepath = f"{cache_dir}/mediascan.json" + if not os.path.exists(filepath): + return True + + os.remove(filepath) + return True + + +def media_get_from_cache(cache_dir): + filepath = f"{cache_dir}/mediascan.json" + if not os.path.exists(filepath): + return None + + with open(filepath, 'r') as f: + return json.load(f) + + def get_media_info_with_ffprobe(file_path): try: if not os.path.exists(file_path): @@ -65,9 +99,14 @@ bitrate = stream_bitrate if stream_bitrate else container_bitrate if codec_type == "video": + if len(media_info["video"]) == 0: + bitrate_str = f"{bitrate} Kbit/s" if bitrate else "Unknown" + else: + bitrate_str = "Unknown" + media_info["video"].append({ "resolution": f"{width}x{height}" if width and height else "Unknown", - "bitrate": f"{bitrate} Kbit/s" if bitrate else "Unknown", + "bitrate": bitrate_str, "codec": codec_name }) elif codec_type == "audio": @@ -125,10 +164,24 @@ def human_readable_size(size_bytes): if size_bytes < 1024: - return [f"{size_bytes}", "B"] + return [f"{size_bytes}", "B", f"{size_bytes}"] elif size_bytes < 1024**2: - return [f"{size_bytes / 1024:.2f}", "KB"] + return [f"{size_bytes / 1024:.2f}", "KB", f"{size_bytes}"] elif size_bytes < 1024**3: - return [f"{size_bytes / 1024**2:.2f}", "MB"] + return [f"{size_bytes / 1024**2:.2f}", "MB", f"{size_bytes}"] else: - return [f"{size_bytes / 1024**3:.2f}", "GB"] \ No newline at end of file + return [f"{size_bytes / 1024**3:.2f}", "GB", f"{size_bytes}"] + +def get_single_media_by_path(path): + media_info = get_media_info_with_ffprobe(path) + size, size_unit, size_bytes = human_readable_size(os.path.getsize(path)) + media_file = { + "path": path, + "name": os.path.basename(path), + "size_bytes": size_bytes, + "size": size, + "size_unit": size_unit, + "info": media_info + } + + return media_file \ No newline at end of file diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..bdb8df9 --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,30 @@ +/* LOADING SPINNER */ + +.loading-spinner-container { + width: 100%; + margin: 50px auto; + text-align: center; +} + +.loader { + width: 48px; + height: 48px; + border: 5px solid #777; + border-bottom-color: transparent; + border-radius: 50%; + display: inline-block; + box-sizing: border-box; + animation: rotation 1s linear infinite; +} + +@keyframes rotation { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* END LOADING SPINNER */ \ No newline at end of file diff --git a/static/js/media.js b/static/js/media.js index 7b00b77..bcf3d51 100644 --- a/static/js/media.js +++ b/static/js/media.js @@ -1,16 +1,28 @@ +function LSpinnerShow() { + $(".loading-spinner-container").removeClass("d-none"); +} + +function LSpinnerHide() { + $(".loading-spinner-container").addClass("d-none"); +} + // Function to load media data function loadMediaList() { + LSpinnerShow(); + $("#media-table").addClass("d-none"); + + const tableBody = $("#media-table tbody"); + tableBody.empty(); + $.ajax({ url: "/media-list", method: "GET", success: function(data) { - const tableBody = $("#media-table tbody"); - tableBody.empty(); // Clear existing rows if (data.length > 0) { data.forEach((file, index) => { const videoInfo = file.info.video - ? file.info.video.map(v => `
  • Resolution: ${v.resolution}, Codec: ${v.codec}, Bitrate: ${v.bitrate}
  • `).join("") + ? file.info.video.map((v, i) => !i ? `
  • Resolution: ${v.resolution}, Codec: ${v.codec}, Bitrate: ${v.bitrate}
  • ` : `
  • Resolution: ${v.resolution}, Codec: ${v.codec}
  • `).join("") : "
  • No video streams
  • "; const audioInfo = file.info.audio @@ -24,15 +36,17 @@ `; + const pathEncoded = encodeURIComponent(file.path); + tableBody.append(` ${index + 1} -
    ${file.name}
    +
    ${file.name}
    ${file.path}
    ${details}
    - ${file.size} + ${file.size}${file.size_unit} `); }); @@ -40,15 +54,50 @@ tableBody.append(`No files found`); } - $('#media-table').DataTable(); + if(!$('#media-table_wrapper').length) { + $('#media-table').DataTable({ + columnDefs: [ + { + targets: "_all", + render: function(data, type, row, meta) { + var cell = meta.settings.aoData[meta.row].anCells[meta.col]; + if (type === 'sort' && cell) { + var orderValue = $(cell).attr('data-order'); + return orderValue !== undefined ? orderValue : data; + } + return data; + } + } + ] + }); + } + + LSpinnerHide(); + $("#media-table").removeClass("d-none"); }, error: function(xhr, status, error) { + LSpinnerHide(); console.error("Error loading media list:", error); } }); } +function rescanMediaLibHandler() { + $("#do-rescan-media-lib").on("click", function(){ + $.getJSON("/media-list/clear-cache").done(function(resp){ + if(resp.status) { + return loadMediaList(); + } + + console.error("Error of rescan media library"); + // TODO: make alert bar + alert("Error of rescan media library"); + }); + }); +} + // Load media list when the page is ready $(document).ready(function() { loadMediaList(); + rescanMediaLibHandler(); }); \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 949e3c7..af8ba01 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,31 +1,28 @@ - - - - - - Media Library Manager - - - - - - - -
    -

    Media Library Manager

    +{% include "partials/head.html" %} + +
    +

    Media Library Manager

    +
    Configure Directories - - - - - - - - - - - -
    #FileSize
    +
    - - + +
    + +
    + + + + + + + + + + + + +
    #FileSize
    +
    + +{% include "partials/footer.html" %} \ No newline at end of file diff --git a/templates/partials/footer.html b/templates/partials/footer.html new file mode 100644 index 0000000..691287b --- /dev/null +++ b/templates/partials/footer.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/templates/partials/head.html b/templates/partials/head.html new file mode 100644 index 0000000..fa7efeb --- /dev/null +++ b/templates/partials/head.html @@ -0,0 +1,15 @@ + + + + + + Media Library Manager + + + + + + + + + \ No newline at end of file diff --git a/templates/single.html b/templates/single.html new file mode 100644 index 0000000..521f745 --- /dev/null +++ b/templates/single.html @@ -0,0 +1,89 @@ +{% include "partials/head.html" %} + +
    +

    {{ file.name }}

    +

    Path: {{ file.path }}

    + + +
    +
    +

    Container Information

    +
    +
    +

    Bitrate: {{ file.info.container.bitrate }}

    +

    Duration: {{ file.info.container.duration }}

    +

    Size: {{ file.info.container.size }}

    +
    +
    + + +
    +
    +

    Video Streams

    +
    +
    + + + + + + + + + + {% for video in file.info.video %} + + + + + + {% endfor %} + +
    BitrateCodecResolution
    {{ video.bitrate }}{{ video.codec }}{{ video.resolution }}
    +
    +
    + + +
    +
    +

    Audio Streams

    +
    +
    + + + + + + + + + + + + {% for audio in file.info.audio %} + + + + + + + + {% endfor %} + +
    BitrateChannelsCodecLanguageLayout
    {{ audio.bitrate }}{{ audio.channels }}{{ audio.codec }}{{ audio.language }}{{ audio.layout }}
    +
    +
    + + +
    +
    +

    File Information

    +
    +
    +

    Size: {{ file.size }} {{ file.size_unit }}

    +

    Size in Bytes: {{ file.size_bytes }}

    +
    +
    +
    + +{% include "partials/footer.html" %} \ No newline at end of file