diff --git a/app.py b/app.py index 363b79b..f90bfb9 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,7 @@ from flask import Flask, render_template, request, jsonify import os import json -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 mediascan import scan_medialib, 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 from flask_socketio import SocketIO import shutil @@ -14,12 +14,21 @@ CONFIG_FILE = 'config.json' config = load_config(CONFIG_FILE) +GStorage = { + "scaning_state": "inaction", # values: inaction, inprogress + "outdated_cache": [] +} + transcoding_tasks = {} + +# ROUTING + @app.route('/') def index(): return render_template('index.html') + @app.route("/single") def single_media(): path = request.args.get("path", "") @@ -31,6 +40,7 @@ return render_template('single.html', file=media_file) + @app.route("/single-json") def single_media_json(): path = request.args.get("path", "") @@ -41,30 +51,26 @@ return jsonify(get_single_media_by_path(path_to_mediafile)) + @app.route('/media-list/clear-cache') def media_list_clear_cache(): - return jsonify({"status": media_remove_cache(config["cache_dir"])}) + media_remove_cache(config["cache_dir"], GStorage) + scan_medialib(config, GStorage, socketio) + return jsonify({"status": True}) + @app.route('/media-list', methods=['GET']) def media_list(): filtered_files = media_get_from_cache(config["cache_dir"]) if filtered_files == None: - media_files = list_media_files(config.get("directories", [])) + filtered_files = scan_medialib(config, GStorage, socketio) - 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"} + if GStorage["scaning_state"] == "inprogress" and not len(filtered_files): + response = {"status": "scaning", "data": []}; + else: + response = {"status": True, "data": filtered_files} + return jsonify(response) - media_create_cache(config["cache_dir"], filtered_files) - return jsonify(filtered_files) @app.route('/process-media', methods=['POST']) def process_media(): @@ -108,6 +114,7 @@ return jsonify({"status": "processing started", "file": file_path}), 202 + @app.route('/stop-transcoding', methods=['GET']) def stop_transcoding(): task_id = request.args.get("task_id") @@ -120,6 +127,7 @@ return jsonify({"status": "error", "message": "Task not found"}), 404 + @app.route('/configure', methods=['GET', 'POST']) def configure(): if request.method == 'POST': @@ -131,5 +139,6 @@ return jsonify({"status": "success"}) return jsonify(config) + if __name__ == '__main__': app.run(debug=True) diff --git a/config.json b/config.json index 294b168..45fe8a5 100644 --- a/config.json +++ b/config.json @@ -1,8 +1,6 @@ { "directories": [ - "/home/gmikcon/media-storage/firstraid5", - "/home/gmikcon/media-storage/NetTera1", - "/home/gmikcon/media-storage/secraid5_storage" + "/home/gmikcon/media-storage/NetTera1" ], "allowed_formats": [".mp4", ".m4p", ".m4v", ".mkv", ".webm", ".flv", ".vob", ".ogv", ".amv", ".mng", ".avi", ".mov", ".viv"], "cache_dir": "cache", diff --git a/mediascan.py b/mediascan.py index e27f3d1..b9188f7 100644 --- a/mediascan.py +++ b/mediascan.py @@ -3,6 +3,7 @@ import ffmpeg import subprocess + def load_config(config_file): if os.path.exists(config_file): with open(config_file, 'r') as f: @@ -10,16 +11,23 @@ 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): + +def media_remove_cache(cache_dir, GStorage): + GStorage["outdated_cache"] = media_get_from_cache(cache_dir) + if GStorage["outdated_cache"] == None: + GStorage["outdated_cache"] = [] + filepath = f"{cache_dir}/mediascan.json" if not os.path.exists(filepath): return True @@ -150,6 +158,7 @@ print(f"Directory does not exist: {directory}") return media_files + def filter_media_files(media_files, allowed_formats): """ Фильтрует список файлов по допустимым форматам. @@ -164,6 +173,7 @@ ] return filtered_files + def human_readable_size(size_bytes): if size_bytes < 1024: return [f"{size_bytes}", "B", f"{size_bytes}"] @@ -174,6 +184,7 @@ else: 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)) @@ -186,4 +197,44 @@ "info": media_info } - return media_file \ No newline at end of file + return media_file + + +def bg_scan_medialib(socketio, config, GStorage): + GStorage["scaning_state"] = "inprogress"; + 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"} + + GStorage["scaning_state"] = "inaction" + + media_create_cache(config["cache_dir"], filtered_files) + + socketio.emit("medialib-scaning-complete", { + "data": filtered_files + }); + pass + + +def scan_medialib(config, GStorage, socketio): + if GStorage["scaning_state"] == "inprogress": + return GStorage["outdated_cache"] + + socketio.start_background_task( + target = bg_scan_medialib, + socketio=socketio, + config = config, + GStorage = GStorage + ) + + pass \ No newline at end of file diff --git a/scss/pages/single.scss b/scss/pages/single.scss new file mode 100644 index 0000000..92e47ed --- /dev/null +++ b/scss/pages/single.scss @@ -0,0 +1,5 @@ +#page-single { + #single-transcoding-progress { + + } +} \ No newline at end of file diff --git a/static/js/components/global-transcoding-tasks.js b/static/js/components/global-transcoding-tasks.js index e4444b1..478a022 100644 --- a/static/js/components/global-transcoding-tasks.js +++ b/static/js/components/global-transcoding-tasks.js @@ -56,18 +56,14 @@ fileLink.text(data.task.file.split("/").at(-1)); fileLink.attr("href", `/single?path=${ data.task.file }`); - let time = "00:00:00"; - if(data.message.indexOf("time=") != -1 && data.message.indexOf("time=N/A") == -1) { - time = data.message.split("time=")[1].split(" ")[0]; - } - + let time = getTimeFromTranscodingProgressMessage(data.message); const mInf = getMediaInfo(data.task.file); if(!mInf) { return; } - const progressPercent = Math.floor(timeToSeconds(time) / parseInt(mInf.info.container.duration) * 100); + const progressPercent = calcTranscodingProgress(time, mInf.info.container.duration); taskContainer.find(".file .progress-percent").text(`- ${ progressPercent }%`); taskContainer.find(".file .circle-progress-bar")[0].dataset.value = progressPercent; diff --git a/static/js/components/media-list.js b/static/js/components/media-list.js index bef1e85..58dfc87 100644 --- a/static/js/components/media-list.js +++ b/static/js/components/media-list.js @@ -1,102 +1,129 @@ function LSpinnerShow() { - $(".loading-spinner-container").removeClass("d-none"); + $(".loading-spinner-container").removeClass("d-none"); } function LSpinnerHide() { - $(".loading-spinner-container").addClass("d-none"); + $(".loading-spinner-container").addClass("d-none"); } // Function to load media data function loadMediaList() { - LSpinnerShow(); - $("#media-table").addClass("d-none"); + LSpinnerShow(); + $("#media-table").addClass("d-none"); + $("#media-table_wrapper").addClass("d-none"); - const tableBody = $("#media-table tbody"); - tableBody.empty(); + const tableBody = $("#media-table tbody"); + tableBody.empty(); - $.ajax({ - url: "/media-list", - method: "GET", - success: function(data) { + $.ajax({ + url: "/media-list", + method: "GET", + success: function(resp) { + if(resp.status == "scaning" && !resp.data.length) { + return; + } - if (data.length > 0) { - data.forEach((file, index) => { - const videoInfo = file.info.video - ? 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
  • "; + let data = resp.data; - const audioInfo = file.info.audio - ? file.info.audio.map(a => `
  • ${a.title}, Channels: ${a.layout}, Codec: ${a.codec}, Bitrate: ${a.bitrate}, Language: ${a.language}
  • `).join("") - : "
  • No audio streams
  • "; - - const details = ` - Video: - - Audio: - - `; - - const pathEncoded = encodeURIComponent(file.path); - - tableBody.append(` - - ${index + 1} - -
    ${file.name}
    -
    ${file.path}
    -
    ${details}
    - - ${file.size}${file.size_unit} - - `); - }); - } else { - tableBody.append(`No files found`); - } - - 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); - } - }); + renderMediaList(data); + }, + 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(); - } + $("#do-rescan-media-lib").on("click", function(){ + $.getJSON("/media-list/clear-cache").done(function(resp){ + if(resp.status) { + return; + // $('#media-table').DataTable().destroy(); + // return loadMediaList(); + } - console.error("Error of rescan media library"); - // TODO: make alert bar - alert("Error of rescan media library"); - }); - }); + console.error("Error of rescan media library"); + // TODO: make alert bar + alert("Error of rescan media library"); + }); + }); +} + +function renderMediaList(data) { + const tableBody = $("#media-table tbody"); + + if (data.length > 0) { + data.forEach((file, index) => { + const videoInfo = file.info.video + ? 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 + ? file.info.audio.map(a => `
  • ${a.title}, Channels: ${a.layout}, Codec: ${a.codec}, Bitrate: ${a.bitrate}, Language: ${a.language}
  • `).join("") + : "
  • No audio streams
  • "; + + const details = ` + Video: + + Audio: + + `; + + const pathEncoded = encodeURIComponent(file.path); + + tableBody.append(` + + ${index + 1} + +
    ${file.name}
    +
    ${file.path}
    +
    ${details}
    + + ${file.size}${file.size_unit} + + `); + }); + } else { + tableBody.append(`No files found`); + } + + if(!$('#media-table_wrapper').length && $('#media-table tbody td').length > 1) { + initMediaListTable(); + } + + LSpinnerHide(); + $("#media-table_wrapper").removeClass("d-none"); + $("#media-table").removeClass("d-none"); +} + +function initMediaListTable() { + $('#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; + } + } + ] + }); } $(document).ready(function() { - loadMediaList(); - rescanMediaLibHandler(); + loadMediaList(); + rescanMediaLibHandler(); + + socket.on("medialib-scaning-complete", resp => { + if($('#media-table_wrapper').length) { + $('#media-table').DataTable().destroy(); + } + + renderMediaList(resp.data); + }); }); \ No newline at end of file diff --git a/static/js/components/progress-bar-line.js b/static/js/components/progress-bar-line.js new file mode 100644 index 0000000..ffadd48 --- /dev/null +++ b/static/js/components/progress-bar-line.js @@ -0,0 +1,14 @@ +$(document).ready(function(){ + $(".progress-bar-line").each(function() { + this.setProgressValue = val => { + if($(this).attr("aria-valuenow") == val) { + return; + } + + $(this).attr("aria-valuenow", val); + const bar = $(this).find(".progress-bar"); + bar.css("width", `${ val }%`); + bar.text(`${ val }%`); + } + }); +}); \ No newline at end of file diff --git a/static/js/components/single-transcoding.js b/static/js/components/single-transcoding.js index cf94fd1..3f77ca4 100644 --- a/static/js/components/single-transcoding.js +++ b/static/js/components/single-transcoding.js @@ -44,6 +44,20 @@ $(".stop-transcoding").show(); $(".stop-transcoding").attr("data-task_id", data.task.id); $(".transcodate-form-container .run-transcodate").hide(); + + let time = getTimeFromTranscodingProgressMessage(data.message); + const mInf = getMediaInfo(data.task.file); + + if(!mInf) { + return; + } + const progressPercentage = calcTranscodingProgress(time, mInf.info.container.duration); + + const progressBar = $(".progress-bar-container"); + progressBar.find(".progress-bar-line")[0].setProgressValue(progressPercentage); + if(!progressBar.is(":visible")) { + progressBar.show(); + } }); // Listen for completion @@ -56,6 +70,8 @@ $(".show-transcodate-form").show(); $(".stop-transcoding").hide(); $(".transcodate-form-container .run-transcodate").show(); + $(".progress-bar-container .progress-bar-line")[0].setProgressValue(0); + $(".progress-bar-container").hide(); }); // Listen for errors @@ -69,6 +85,8 @@ $(".show-transcodate-form").show(); $(".stop-transcoding").hide(); $(".transcodate-form-container .run-transcodate").show(); + $(".progress-bar-container .progress-bar-line")[0].setProgressValue(0); + $(".progress-bar-container").hide(); }); socket.on("canceled", (data) => { @@ -80,6 +98,8 @@ $(".stop-transcoding").hide(); $(".show-transcodate-form").show(); $(".transcodate-form-container .run-transcodate").show(); + $(".progress-bar-container .progress-bar-line")[0].setProgressValue(0); + $(".progress-bar-container").hide(); }); $(".transcodate-form-container .run-transcodate").on("click", async () => { diff --git a/static/js/lib.js b/static/js/lib.js index d722c6d..30f2df2 100644 --- a/static/js/lib.js +++ b/static/js/lib.js @@ -2,4 +2,17 @@ $.getJSON(`/single-json?path=${ filepath }`, function(resp) { callback(resp); }); +} + +function getTimeFromTranscodingProgressMessage(message) { + let time = "00:00:00"; + if(message.indexOf("time=") != -1 && message.indexOf("time=N/A") == -1) { + time = message.split("time=")[1].split(" ")[0]; + } + + return time; +} + +function calcTranscodingProgress(time, duration) { + return Math.floor(timeToSeconds(time) / parseInt(duration) * 100); } \ No newline at end of file diff --git a/storage.json b/storage.json new file mode 100644 index 0000000..4eb7c3f --- /dev/null +++ b/storage.json @@ -0,0 +1,3 @@ +{ + +} \ No newline at end of file diff --git a/templates/components/progress-bar-line.html b/templates/components/progress-bar-line.html new file mode 100644 index 0000000..494bb87 --- /dev/null +++ b/templates/components/progress-bar-line.html @@ -0,0 +1,10 @@ +
    +
    0%
    +
    \ No newline at end of file diff --git a/templates/components/single-transcoding.html b/templates/components/single-transcoding.html index 4698aa9..6693754 100644 --- a/templates/components/single-transcoding.html +++ b/templates/components/single-transcoding.html @@ -76,7 +76,11 @@ + + diff --git a/templates/partials/footer.html b/templates/partials/footer.html index 826f5e0..1da395d 100644 --- a/templates/partials/footer.html +++ b/templates/partials/footer.html @@ -1,3 +1,7 @@ + + \ No newline at end of file diff --git a/templates/partials/head.html b/templates/partials/head.html index 9113f41..45cc01e 100644 --- a/templates/partials/head.html +++ b/templates/partials/head.html @@ -14,6 +14,7 @@ + diff --git a/templates/single.html b/templates/single.html index 959df38..7400473 100644 --- a/templates/single.html +++ b/templates/single.html @@ -4,7 +4,7 @@ const mediaInfo = JSON.parse("{{ file }}".replaceAll("'", '"').replaceAll(""", "'")); -
    +

    {{ file.name }}

    Path: {{ file.path }}

    Back to mediafiles list