diff --git a/README.md b/README.md index e68e62a2..35325eef 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,8 @@ Hotkeys are ignored while typing in an input/textarea. ### Assets Manager (grid) -- `Tab`: focus/open the Assets Manager sidebar tab. - `0`-`5`: set rating for the current file (0 clears). -- `Enter`: open viewer (1 selected) or A/B compare (2 selected; uses the first two). +- `Space`: open viewer (1 selected) or A/B compare (2 selected; uses the first two). ### Viewer diff --git a/js/am_cards.js b/js/am_cards.js index d4dd2741..4ff71a87 100644 --- a/js/am_cards.js +++ b/js/am_cards.js @@ -9,6 +9,43 @@ import { mjrSettings, } from "./ui_settings.js"; +function formatCardHoverTitle(file) { + const rawName = file?.name || file?.filename || "(unnamed)"; + const ext = String(file?.ext || getExt(rawName) || "").toUpperCase(); + const kind = file?.kind || detectKindFromExt(ext); + const subfolder = file?.subfolder ? String(file.subfolder) : "(root)"; + const sizeReadable = file?.size_readable ? String(file.size_readable) : null; + const sizeBytes = Number.isFinite(file?.size) ? `${file.size} B` : null; + const size = sizeReadable || sizeBytes || null; + const date = file?.date ? String(file.date) : null; + + const rating = Math.max(0, Math.min(5, Number(file?.rating ?? (file?.meta && file.meta.rating) ?? 0) || 0)); + const tags = + (Array.isArray(file?.tags) && file.tags) || + (Array.isArray(file?.meta && file.meta.tags) && file.meta.tags) || + []; + + const wfState = resolveWorkflowState(file); + + const lines = []; + lines.push(rawName); + lines.push(`Folder: ${subfolder}`); + lines.push(`Type: ${kind}${ext ? ` (${ext})` : ""}`); + if (date) lines.push(`Date: ${date}`); + if (size) lines.push(`Size: ${size}`); + lines.push(`Rating: ${rating}/5`); + if (tags.length) { + const maxTags = 20; + const shown = tags.slice(0, maxTags).map((t) => String(t)).join(", "); + const more = tags.length > maxTags ? ` (+${tags.length - maxTags} more)` : ""; + lines.push(`Tags: ${shown}${more}`); + } else { + lines.push("Tags: (none)"); + } + lines.push(`Workflow: ${wfState}`); + return lines.join("\n"); +} + export function resolveWorkflowState(file) { const toState = (val) => { if (val === true || val === 1 || val === "true") return "yes"; @@ -118,6 +155,16 @@ export function updateCardVisuals(card, file) { (Array.isArray(file.meta && file.meta.tags) && file.meta.tags) || []; + if (mjrSettings.grid.hoverInfo) { + try { + card.title = formatCardHoverTitle(file); + } catch (_) { + card.title = ""; + } + } else { + card.title = ""; + } + const wfState = resolveWorkflowState(file); updateWorkflowDot(card, wfState); diff --git a/js/grid/fetch.js b/js/grid/fetch.js index c8e8f109..c4a37d65 100644 --- a/js/grid/fetch.js +++ b/js/grid/fetch.js @@ -1,10 +1,40 @@ import { api } from "../../../../scripts/api.js"; -export function createMetadataFetcher(state, gridView) { +export function createMetadataFetcher(state, gridView, opts = {}) { let metadataAbortCtrl = null; let metadataFetchInFlight = null; let metadataDebounceTimer = null; let currentGridView = gridView || null; + let onMetadataUpdated = typeof opts.onMetadataUpdated === "function" ? opts.onMetadataUpdated : null; + + const setOnMetadataUpdated = (fn) => { + onMetadataUpdated = typeof fn === "function" ? fn : null; + }; + + const shouldHydrateForFilter = () => { + const tagFilter = (state.tagFilter || "").trim(); + const minRating = Number(state.minRating || 0); + return !!tagFilter || minRating > 0; + }; + + const applyBatchResults = (data) => { + const metaByKey = new Map(); + (data.metadatas || []).forEach((m) => metaByKey.set(`${m.subfolder || ""}/${m.filename || ""}`, m)); + + let updatedCount = 0; + state.files.forEach((f) => { + const key = `${f.subfolder || ""}/${f.filename || f.name || ""}`; + const m = metaByKey.get(key); + if (m) { + f.rating = m.rating ?? 0; + f.tags = m.tags || []; + if (m.has_workflow !== undefined) f.hasWorkflow = !!m.has_workflow; + f.__metaLoaded = true; + updatedCount += 1; + } + }); + return updatedCount; + }; async function fetchMetadataForVisible() { if (metadataFetchInFlight) return; @@ -37,26 +67,14 @@ export function createMetadataFetcher(state, gridView) { signal: thisCtrl.signal, }); + let updatedAny = false; try { const res = await metadataFetchInFlight; if (thisCtrl.signal.aborted || reqVersion !== state.renderVersion) return; if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); if (thisCtrl.signal.aborted || reqVersion !== state.renderVersion) return; - - const metaByKey = new Map(); - (data.metadatas || []).forEach((m) => metaByKey.set(`${m.subfolder || ""}/${m.filename || ""}`, m)); - - state.files.forEach((f) => { - const key = `${f.subfolder || ""}/${f.filename || f.name || ""}`; - const m = metaByKey.get(key); - if (m) { - f.rating = m.rating ?? 0; - f.tags = m.tags || []; - if (m.has_workflow !== undefined) f.hasWorkflow = !!m.has_workflow; - f.__metaLoaded = true; - } - }); + updatedAny = applyBatchResults(data) > 0; if (currentGridView) currentGridView.renderGrid(); } catch (err) { @@ -68,12 +86,76 @@ export function createMetadataFetcher(state, gridView) { } finally { metadataFetchInFlight = null; if (metadataAbortCtrl === thisCtrl) metadataAbortCtrl = null; - if (fullList.slice(effectiveStart, effectiveEnd).some((f) => !f.__metaLoaded)) { + if (updatedAny && onMetadataUpdated) { + setTimeout(() => { + try { + onMetadataUpdated(); + } catch (_) {} + }, 0); + } + if (reqVersion === state.renderVersion && fullList.slice(effectiveStart, effectiveEnd).some((f) => !f.__metaLoaded)) { fetchMetadataForVisible(); } } } + async function fetchMetadataForFilter() { + if (metadataFetchInFlight) return; + if (!shouldHydrateForFilter()) return; + + const reqVersion = state.renderVersion; + const allFiles = state.files || []; + const targetFiles = allFiles.filter((f) => !f.__metaLoaded).slice(0, 30); + if (!targetFiles.length) return; + + const batch = targetFiles.map((f) => ({ filename: f.filename || f.name, subfolder: f.subfolder || "" })); + + if (metadataAbortCtrl) { + try { + metadataAbortCtrl.abort(); + } catch (_) {} + } + metadataAbortCtrl = new AbortController(); + const thisCtrl = metadataAbortCtrl; + + metadataFetchInFlight = api.fetchApi("/mjr/filemanager/metadata/batch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items: batch }), + signal: thisCtrl.signal, + }); + + let updatedAny = false; + try { + const res = await metadataFetchInFlight; + if (thisCtrl.signal.aborted || reqVersion !== state.renderVersion) return; + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + if (thisCtrl.signal.aborted || reqVersion !== state.renderVersion) return; + updatedAny = applyBatchResults(data) > 0; + if (currentGridView) currentGridView.renderGrid(); + } catch (err) { + if (err?.name === "AbortError") { + // ignore aborted fetch + } else { + console.warn("[Majoor.AssetsManager] filter metadata load failed", err); + } + } finally { + metadataFetchInFlight = null; + if (metadataAbortCtrl === thisCtrl) metadataAbortCtrl = null; + if (updatedAny && onMetadataUpdated) { + setTimeout(() => { + try { + onMetadataUpdated(); + } catch (_) {} + }, 0); + } + if (reqVersion === state.renderVersion && shouldHydrateForFilter() && allFiles.some((f) => !f.__metaLoaded)) { + fetchMetadataForFilter(); + } + } + } + const onRequestMetadata = (start, end) => { state.visibleStart = start; state.visibleEnd = end; @@ -94,5 +176,5 @@ export function createMetadataFetcher(state, gridView) { currentGridView = gv; }; - return { fetchMetadataForVisible, onRequestMetadata, cleanup, setGridView }; + return { fetchMetadataForVisible, fetchMetadataForFilter, onRequestMetadata, cleanup, setGridView, setOnMetadataUpdated }; } diff --git a/js/grid/render.js b/js/grid/render.js index 2775208d..449793e2 100644 --- a/js/grid/render.js +++ b/js/grid/render.js @@ -1,7 +1,7 @@ import { detectKindFromExt, getBaseName, getExt, mjrSettings } from "../ui_settings.js"; import { normalizeMtimeValue } from "../am_filters.js"; -export function createApplyFilterAndRender(state, fetchMetadataForVisible) { +export function createApplyFilterAndRender(state, fetchMetadataForVisible, fetchMetadataForFilter) { let gridView = null; const applyFilterAndRender = (options = {}) => { @@ -9,6 +9,7 @@ export function createApplyFilterAndRender(state, fetchMetadataForVisible) { const q = (state.search || "").trim().toLowerCase(); const tagFilter = (state.tagFilter || "").trim().toLowerCase(); const minRating = Number(state.minRating || 0); + const needsMetaFilter = minRating > 0 || !!tagFilter; const hidePng = !!mjrSettings.siblings.hidePngSiblings; let nonImageStems = null; if (hidePng) { @@ -40,9 +41,11 @@ export function createApplyFilterAndRender(state, fetchMetadataForVisible) { } const fileRating = Number(file.rating ?? 0); + const hasMeta = !!file.__metaLoaded || file.rating !== undefined || Array.isArray(file.tags); + if (needsMetaFilter && !hasMeta) return false; if (fileRating < minRating) return false; - const tagsArr = file.tags || []; + const tagsArr = Array.isArray(file.tags) ? file.tags : []; if (tagFilter && !tagsArr.some((tag) => String(tag).toLowerCase().includes(tagFilter))) return false; return true; @@ -55,6 +58,9 @@ export function createApplyFilterAndRender(state, fetchMetadataForVisible) { if (!skipMetadataFetch) { fetchMetadataForVisible(); + if (needsMetaFilter && typeof fetchMetadataForFilter === "function") { + fetchMetadataForFilter(); + } } }; diff --git a/js/ui_grid.js b/js/ui_grid.js index f5565a49..2314ebb5 100644 --- a/js/ui_grid.js +++ b/js/ui_grid.js @@ -1,16 +1,30 @@ -import { app } from "../../../../scripts/app.js"; -import { api } from "../../../../scripts/api.js"; -import { createEl, mjrAttachHoverFeedback, mjrCardBasePx, mjrDeepMerge, mjrSaveSettings, mjrSettings, mjrSettingsDefaults, mjrShowToast, setMjrSettings, detectKindFromExt } from "./ui_settings.js"; +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; +import { + createEl, + mjrAttachHoverFeedback, + mjrCardBasePx, + mjrDeepMerge, + mjrPrefetchOutputs, + mjrResetPrefetch, + mjrSaveSettings, + mjrSettings, + mjrSettingsDefaults, + mjrShowToast, + setMjrSettings, + detectKindFromExt, +} from "./ui_settings.js"; import { mjrGlobalState } from "./mjr_global.js"; import { fileKey } from "./am_state.js"; -import { SMART_FILTERS } from "./am_filters.js"; -import { mjrCreateContextMenu } from "./am_context_menu.js"; -import { - mjrOpenABViewer, - mjrOpenViewerForFiles, - mjrStepVideos, - mjrUpdateNavVisibility, -} from "./ui_viewer.js"; +import { SMART_FILTERS } from "./am_filters.js"; +import { mjrCreateContextMenu } from "./am_context_menu.js"; +import { + mjrOpenABViewer, + mjrOpenViewerForFiles, + mjrStepVideos, + mjrApplyOpenViewerSettings, + mjrUpdateNavVisibility, +} from "./ui_viewer.js"; import { createMetadataSidebar } from "./ui_sidebar.js"; import { createGridView } from "./am_gridview.js"; import { @@ -20,22 +34,26 @@ import { ensureQueueListener, mjrStartAutoRefreshTimer, } from "./grid/events.js"; -import { createMetadataFetcher } from "./grid/fetch.js"; -import { initManagerState, populateCollectionsOptions } from "./grid/state.js"; -import { createApplyFilterAndRender } from "./grid/render.js"; -import { createRefreshController } from "./grid/load.js"; - -// --------------------------------------------------------------------------- -// Majoor Assets Manager globals + hotkeys -// --------------------------------------------------------------------------- - -let mjrMetaVisible = !!mjrSettings.metaPanel.showByDefault; -let refreshAllInstances = () => Promise.resolve(); - -function mjrApplySettings(newSettings) { - setMjrSettings(mjrDeepMerge(mjrSettingsDefaults, newSettings || {})); - mjrSaveSettings(mjrSettings); - mjrMetaVisible = !!mjrSettings.metaPanel.showByDefault; +import { createMetadataFetcher } from "./grid/fetch.js"; +import { initManagerState, populateCollectionsOptions } from "./grid/state.js"; +import { createApplyFilterAndRender } from "./grid/render.js"; +import { createRefreshController } from "./grid/load.js"; + +// --------------------------------------------------------------------------- +// Majoor Assets Manager globals + hotkeys +// --------------------------------------------------------------------------- + +let mjrMetaVisible = !!mjrSettings.metaPanel.showByDefault; +let refreshAllInstances = () => Promise.resolve(); + +const mjrPanelMode = String(mjrSettings?.integration?.panel || mjrSettingsDefaults?.integration?.panel || "both").toLowerCase(); +const mjrUseBottomPanel = mjrPanelMode === "bottom" || mjrPanelMode === "both"; +const mjrUseSidebarPanel = mjrPanelMode === "sidebar" || mjrPanelMode === "both"; + +function mjrApplySettings(newSettings) { + setMjrSettings(mjrDeepMerge(mjrSettingsDefaults, newSettings || {})); + mjrSaveSettings(mjrSettings); + mjrMetaVisible = !!mjrSettings.metaPanel.showByDefault; mjrStartAutoRefreshTimer(refreshAllInstances); mjrUpdateNavVisibility(); @@ -161,20 +179,30 @@ function renderAssetsManager(root) { populateCollectionsOptions(state, collectionFilterSelect); let openContextMenuEl = null; - const { - fetchMetadataForVisible, - onRequestMetadata, - cleanup: cleanupMetadataFetcher, - setGridView: setGridViewForMetadata, - } = createMetadataFetcher(state, null); - const { - applyFilterAndRender, - setGridView: setGridViewForRender, - } = createApplyFilterAndRender(state, fetchMetadataForVisible); - const { - refreshInstance, - refreshAllInstances: refreshAllInstancesFn, - loadFilesWrapper, + const { + fetchMetadataForVisible, + fetchMetadataForFilter, + onRequestMetadata, + cleanup: cleanupMetadataFetcher, + setGridView: setGridViewForMetadata, + setOnMetadataUpdated, + } = createMetadataFetcher(state, null); + const { + applyFilterAndRender, + setGridView: setGridViewForRender, + } = createApplyFilterAndRender(state, fetchMetadataForVisible, fetchMetadataForFilter); + + setOnMetadataUpdated(() => { + const tagFilter = (state.tagFilter || "").trim(); + const minRating = Number(state.minRating || 0); + if (tagFilter || minRating > 0) { + applyFilterAndRender(); + } + }); + const { + refreshInstance, + refreshAllInstances: refreshAllInstancesFn, + loadFilesWrapper, loadFiles, loadMoreFiles, lastSignatureRef, @@ -329,17 +357,21 @@ function renderAssetsManager(root) { }; } -app.registerExtension({ - name: "Majoor.AssetsManager", - bottomPanelTabs: [{ - id: "majoorAssetsManagerBottom", - title: "Assets Manager", - type: "custom", - render: (el) => renderAssetsManager(el), - destroy: (el) => el?.__mjrCleanup?.(), - }], - async setup() { - const SETTINGS_PREFIX = "MajoorAM"; +app.registerExtension({ + name: "Majoor.AssetsManager", + bottomPanelTabs: mjrUseBottomPanel + ? [ + { + id: "majoorAssetsManagerBottom", + title: "Assets Manager", + type: "custom", + render: (el) => renderAssetsManager(el), + destroy: (el) => el?.__mjrCleanup?.(), + }, + ] + : [], + async setup() { + const SETTINGS_PREFIX = "MajoorAM"; const warnIfNoExiftool = async () => { const warnKey = "mjrExiftoolWarned"; @@ -365,133 +397,354 @@ app.registerExtension({ } }; - // --- GRID SETTINGS --- - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Grid.CardSize`, - name: "Majoor: Card Size", - type: "combo", - options: ["compact", "medium", "large"], - defaultValue: mjrSettingsDefaults.grid.cardSize, - onChange: (val) => { - mjrSettings.grid.cardSize = val; - mjrSaveSettings(mjrSettings); - mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.()); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Grid.ShowTags`, - name: "Majoor: Show Tags on Cards", - type: "boolean", - defaultValue: mjrSettingsDefaults.grid.showTags, - onChange: (val) => { - mjrSettings.grid.showTags = val; - mjrSaveSettings(mjrSettings); - mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.()); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Grid.ShowRating`, - name: "Majoor: Show Ratings on Cards", - type: "boolean", - defaultValue: mjrSettingsDefaults.grid.showRating, - onChange: (val) => { - mjrSettings.grid.showRating = val; - mjrSaveSettings(mjrSettings); - mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.()); - }, - }); - - // --- AUTO REFRESH --- - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Refresh.Enabled`, - name: "Majoor: Auto-Refresh Enabled", - type: "boolean", - defaultValue: mjrSettingsDefaults.autoRefresh.enabled, - onChange: (val) => { - mjrSettings.autoRefresh.enabled = val; - mjrSaveSettings(mjrSettings); - mjrStartAutoRefreshTimer(refreshAllInstances); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Refresh.Interval`, - name: "Majoor: Refresh Interval (ms)", - type: "number", - defaultValue: mjrSettingsDefaults.autoRefresh.interval, - attrs: { min: 1000, step: 500 }, - onChange: (val) => { - mjrSettings.autoRefresh.interval = val; - mjrSaveSettings(mjrSettings); - mjrStartAutoRefreshTimer(refreshAllInstances); - }, - }); - - // --- VIEWER SETTINGS --- - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Viewer.Autoplay`, - name: "Majoor Viewer: Autoplay Videos", - type: "boolean", - defaultValue: mjrSettingsDefaults.viewer.autoplayVideos, - onChange: (val) => { - mjrSettings.viewer.autoplayVideos = val; - mjrSaveSettings(mjrSettings); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Viewer.Loop`, - name: "Majoor Viewer: Loop Videos", - type: "boolean", - defaultValue: mjrSettingsDefaults.viewer.loopVideos, - onChange: (val) => { - mjrSettings.viewer.loopVideos = val; - mjrSaveSettings(mjrSettings); - }, - }); - - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.Viewer.Checkerboard`, - name: "Majoor Viewer: Checkerboard Background", - type: "boolean", - defaultValue: mjrSettingsDefaults.viewer.useCheckerboard, - onChange: (val) => { - mjrSettings.viewer.useCheckerboard = val; - mjrSaveSettings(mjrSettings); - }, - }); - - // --- GENERAL --- - app.ui.settings.addSetting({ - id: `${SETTINGS_PREFIX}.General.HideSiblings`, - name: "Majoor: Hide PNG Siblings (Video previews)", - tooltip: "If a video has a matching .png, hide the .png from the grid", - type: "boolean", - defaultValue: mjrSettingsDefaults.siblings.hidePngSiblings, - onChange: (val) => { - mjrSettings.siblings.hidePngSiblings = val; - mjrSaveSettings(mjrSettings); - mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.()); - }, - }); + // --- INTEGRATION --- + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Integration.Panel`, + name: "Majoor: Panel Integration (reload to apply)", + tooltip: "Choose where the Assets Manager appears (reload ComfyUI page after changing).", + type: "combo", + options: ["both", "sidebar", "bottom"], + defaultValue: mjrSettings.integration?.panel || mjrSettingsDefaults.integration.panel, + onChange: (val) => { + mjrSettings.integration = mjrSettings.integration || {}; + mjrSettings.integration.panel = String(val || "both"); + mjrSaveSettings(mjrSettings); + mjrShowToast("info", "Reload the ComfyUI page to apply the new panel placement.", "Assets Manager"); + }, + }); + + // --- GRID SETTINGS --- + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Grid.CardSize`, + name: "Majoor: Card Size", + type: "combo", + options: ["compact", "medium", "large"], + defaultValue: mjrSettings.grid.cardSize, + onChange: (val) => { + mjrSettings.grid.cardSize = val; + mjrSaveSettings(mjrSettings); + mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.()); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Grid.PageSize`, + name: "Majoor: Page Size (files per request)", + tooltip: "Bigger loads more at once; smaller reduces memory. Applies on next refresh.", + type: "number", + defaultValue: Number(mjrSettings.grid.pageSize) || mjrSettingsDefaults.grid.pageSize, + attrs: { min: 50, max: 2000, step: 50 }, + onChange: (val) => { + const n = Math.max(50, Math.min(2000, Math.floor(Number(val) || mjrSettingsDefaults.grid.pageSize))); + mjrSettings.grid.pageSize = n; + mjrSaveSettings(mjrSettings); + mjrResetPrefetch(); + mjrPrefetchOutputs(); + refreshAllInstances({ silent: true, forceFiles: true }); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Grid.ShowTags`, + name: "Majoor: Show Tags on Cards", + type: "boolean", + defaultValue: mjrSettings.grid.showTags, + onChange: (val) => { + mjrSettings.grid.showTags = val; + mjrSaveSettings(mjrSettings); + mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.()); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Grid.ShowRating`, + name: "Majoor: Show Ratings on Cards", + type: "boolean", + defaultValue: mjrSettings.grid.showRating, + onChange: (val) => { + mjrSettings.grid.showRating = val; + mjrSaveSettings(mjrSettings); + mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.()); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Grid.HoverInfo`, + name: "Majoor: Hover Info Tooltips", + tooltip: "Show file info on hover (native tooltip).", + type: "boolean", + defaultValue: mjrSettings.grid.hoverInfo, + onChange: (val) => { + mjrSettings.grid.hoverInfo = !!val; + mjrSaveSettings(mjrSettings); + mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.()); + }, + }); + + // --- META PANEL --- + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.MetaPanel.ShowByDefault`, + name: "Majoor: Show Metadata Panel", + type: "boolean", + defaultValue: mjrSettings.metaPanel.showByDefault, + onChange: (val) => { + mjrSettings.metaPanel.showByDefault = !!val; + mjrSaveSettings(mjrSettings); + mjrMetaVisible = !!val; + mjrGlobalState.instances.forEach((i) => i.setMetadataPanelVisibility?.(mjrMetaVisible)); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.MetaPanel.RefreshInterval`, + name: "Majoor: Metadata Refresh Interval (ms)", + tooltip: "While a file is selected, re-fetch generation metadata at this interval (0 disables).", + type: "number", + defaultValue: Number(mjrSettings.metaPanel.refreshInterval) || mjrSettingsDefaults.metaPanel.refreshInterval, + attrs: { min: 0, step: 250 }, + onChange: (val) => { + const n = Math.max(0, Math.min(60000, Math.floor(Number(val) || 0))); + mjrSettings.metaPanel.refreshInterval = n; + mjrSaveSettings(mjrSettings); + }, + }); + + // --- AUTO REFRESH --- + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Refresh.Enabled`, + name: "Majoor: Auto-Refresh Enabled", + type: "boolean", + defaultValue: mjrSettings.autoRefresh.enabled, + onChange: (val) => { + mjrSettings.autoRefresh.enabled = val; + mjrSaveSettings(mjrSettings); + mjrStartAutoRefreshTimer(refreshAllInstances); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Refresh.Interval`, + name: "Majoor: Refresh Interval (ms)", + type: "number", + defaultValue: Number(mjrSettings.autoRefresh.interval) || mjrSettingsDefaults.autoRefresh.interval, + attrs: { min: 1000, step: 500 }, + onChange: (val) => { + mjrSettings.autoRefresh.interval = Number(val) || mjrSettingsDefaults.autoRefresh.interval; + mjrSaveSettings(mjrSettings); + mjrStartAutoRefreshTimer(refreshAllInstances); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Refresh.FocusOnly`, + name: "Majoor: Auto-Refresh Only When Focused", + type: "boolean", + defaultValue: mjrSettings.autoRefresh.focusOnly, + onChange: (val) => { + mjrSettings.autoRefresh.focusOnly = !!val; + mjrSaveSettings(mjrSettings); + mjrStartAutoRefreshTimer(refreshAllInstances); + }, + }); + + // --- TOASTS --- + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Toasts.Enabled`, + name: "Majoor: Toasts Enabled", + type: "boolean", + defaultValue: mjrSettings.toasts.enabled, + onChange: (val) => { + mjrSettings.toasts.enabled = !!val; + mjrSaveSettings(mjrSettings); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Toasts.Verbosity`, + name: "Majoor: Toast Verbosity", + type: "combo", + options: ["all", "warnings", "errors", "none"], + defaultValue: mjrSettings.toasts.verbosity || mjrSettingsDefaults.toasts.verbosity, + onChange: (val) => { + mjrSettings.toasts.verbosity = String(val || "all"); + mjrSaveSettings(mjrSettings); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Toasts.Duration`, + name: "Majoor: Toast Duration (ms)", + type: "number", + defaultValue: Number(mjrSettings.toasts.duration) || mjrSettingsDefaults.toasts.duration, + attrs: { min: 500, max: 20000, step: 250 }, + onChange: (val) => { + const n = Math.max(500, Math.min(20000, Math.floor(Number(val) || mjrSettingsDefaults.toasts.duration))); + mjrSettings.toasts.duration = n; + mjrSaveSettings(mjrSettings); + }, + }); + + // --- VIEWER SETTINGS --- + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Viewer.Autoplay`, + name: "Majoor Viewer: Autoplay Videos", + type: "boolean", + defaultValue: mjrSettings.viewer.autoplayVideos, + onChange: (val) => { + mjrSettings.viewer.autoplayVideos = val; + mjrSaveSettings(mjrSettings); + mjrApplyOpenViewerSettings(); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Viewer.Loop`, + name: "Majoor Viewer: Loop Videos", + type: "boolean", + defaultValue: mjrSettings.viewer.loopVideos, + onChange: (val) => { + mjrSettings.viewer.loopVideos = val; + mjrSaveSettings(mjrSettings); + mjrApplyOpenViewerSettings(); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Viewer.Mute`, + name: "Majoor Viewer: Mute Videos", + type: "boolean", + defaultValue: mjrSettings.viewer.muteVideos, + onChange: (val) => { + mjrSettings.viewer.muteVideos = !!val; + mjrSaveSettings(mjrSettings); + mjrApplyOpenViewerSettings(); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Viewer.Navigation`, + name: "Majoor Viewer: Navigation Enabled", + type: "boolean", + defaultValue: mjrSettings.viewer.navEnabled, + onChange: (val) => { + mjrSettings.viewer.navEnabled = !!val; + mjrSaveSettings(mjrSettings); + mjrApplyOpenViewerSettings(); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Viewer.RatingHUD`, + name: "Majoor Viewer: Show Rating HUD", + type: "boolean", + defaultValue: mjrSettings.viewer.showRatingHUD, + onChange: (val) => { + mjrSettings.viewer.showRatingHUD = !!val; + mjrSaveSettings(mjrSettings); + mjrApplyOpenViewerSettings(); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Viewer.RatingHotkeys`, + name: "Majoor Viewer: Rating Hotkeys (0-5)", + type: "boolean", + defaultValue: mjrSettings.viewer.ratingHotkeys, + onChange: (val) => { + mjrSettings.viewer.ratingHotkeys = !!val; + mjrSaveSettings(mjrSettings); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Viewer.Checkerboard`, + name: "Majoor Viewer: Checkerboard Background", + type: "boolean", + defaultValue: mjrSettings.viewer.useCheckerboard, + onChange: (val) => { + mjrSettings.viewer.useCheckerboard = val; + mjrSaveSettings(mjrSettings); + }, + }); + + // --- HOTKEYS --- + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Hotkeys.Enabled`, + name: "Majoor: Hotkeys Enabled", + type: "boolean", + defaultValue: mjrSettings.hotkeys.enabled, + onChange: (val) => { + mjrSettings.hotkeys.enabled = !!val; + mjrSaveSettings(mjrSettings); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Hotkeys.Rating`, + name: "Majoor: Rating Hotkeys in Grid (0-5)", + type: "boolean", + defaultValue: mjrSettings.hotkeys.rating, + onChange: (val) => { + mjrSettings.hotkeys.rating = !!val; + mjrSaveSettings(mjrSettings); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Hotkeys.SpaceOpen`, + name: "Majoor: Open Viewer Hotkey (Space)", + type: "boolean", + defaultValue: mjrSettings.hotkeys.enterOpen, + onChange: (val) => { + mjrSettings.hotkeys.enterOpen = !!val; + mjrSaveSettings(mjrSettings); + }, + }); + + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.Hotkeys.FrameStep`, + name: "Majoor: Viewer Frame-Step Hotkeys (ArrowUp/Down)", + type: "boolean", + defaultValue: mjrSettings.hotkeys.frameStep, + onChange: (val) => { + mjrSettings.hotkeys.frameStep = !!val; + mjrSaveSettings(mjrSettings); + }, + }); + + // --- GENERAL --- + app.ui.settings.addSetting({ + id: `${SETTINGS_PREFIX}.General.HideSiblings`, + name: "Majoor: Hide PNG Siblings (Video previews)", + tooltip: "If a video has a matching .png, hide the .png from the grid", + type: "boolean", + defaultValue: mjrSettings.siblings.hidePngSiblings, + onChange: (val) => { + mjrSettings.siblings.hidePngSiblings = val; + mjrSaveSettings(mjrSettings); + mjrGlobalState.instances.forEach((i) => i.applyFilterAndRender?.()); + }, + }); warnIfNoExiftool(); - try { - app.extensionManager.registerSidebarTab({ - id: "majoorAssetsManagerSidebar", - icon: "pi pi-folder-open", - title: "Assets Manager", - tooltip: "Majoor Assets Manager", - type: "custom", - render: (el) => renderAssetsManager(el), - destroy: (el) => el?.__mjrCleanup?.(), - }); - } catch (err) { console.error("[Majoor.AssetsManager] sidebar registration failed", err); } - }, -}); + if (mjrUseSidebarPanel && app.extensionManager?.registerSidebarTab) { + try { + app.extensionManager.registerSidebarTab({ + id: "majoorAssetsManagerSidebar", + icon: "pi pi-folder-open", + title: "Assets Manager", + tooltip: "Majoor Assets Manager", + type: "custom", + render: (el) => renderAssetsManager(el), + destroy: (el) => el?.__mjrCleanup?.(), + }); + } catch (err) { + console.error("[Majoor.AssetsManager] sidebar registration failed", err); + } + } + }, +}); if (!window.__MajoorAssetsManagerHotkeysInitialized) { window.__MajoorAssetsManagerHotkeysInitialized = true; diff --git a/js/ui_settings.js b/js/ui_settings.js index 3d446ad6..8f19bfd8 100644 --- a/js/ui_settings.js +++ b/js/ui_settings.js @@ -101,12 +101,13 @@ export const CONTEXT_MENU_STYLES = { export const mjrSettingsDefaults = { autoRefresh: { enabled: true, interval: 5000, focusOnly: true }, metaPanel: { showByDefault: true, refreshInterval: 2000 }, - grid: { cardSize: "medium", showTags: true, showRating: true, pageSize: 500 }, + grid: { cardSize: "medium", showTags: true, showRating: true, pageSize: 500, hoverInfo: true }, + integration: { panel: "both" }, // "both" | "sidebar" | "bottom" viewer: { navEnabled: true, autoplayVideos: true, loopVideos: true, - muteVideos: true, + muteVideos: true, showRatingHUD: true, ratingHotkeys: true, useCheckerboard: false, @@ -189,19 +190,24 @@ export async function mjrPrefetchOutputs() { mjrPrefetchedFiles = null; }); return mjrPrefetchPromise; -} - -export function mjrShouldShowToast(kind) { - if (!mjrSettings.toasts.enabled) return false; - const v = mjrSettings.toasts.verbosity || "all"; - if (v === "all") return true; +} + +export function mjrResetPrefetch() { + mjrPrefetchedFiles = null; + mjrPrefetchPromise = null; +} + +export function mjrShouldShowToast(kind) { + if (!mjrSettings.toasts.enabled) return false; + const v = mjrSettings.toasts.verbosity || "all"; + if (v === "all") return true; if (v === "errors") return kind === "error"; if (v === "warnings") return kind === "warn" || kind === "error"; return false; } -export function mjrShowToast(kind = "info", message = "", title = null, duration = null) { - if (!mjrShouldShowToast(kind)) return; +export function mjrShowToast(kind = "info", message = "", title = null, duration = null) { + if (!mjrShouldShowToast(kind)) return; const severityMap = { info: "info", @@ -215,14 +221,14 @@ export function mjrShowToast(kind = "info", message = "", title = null, duration return; } - app.toast.add({ - severity: severityMap[kind] || "info", - summary: title || (kind.charAt(0).toUpperCase() + kind.slice(1)), - detail: message, - life: duration || 3000, - closable: true, - }); -} + app.toast.add({ + severity: severityMap[kind] || "info", + summary: title || (kind.charAt(0).toUpperCase() + kind.slice(1)), + detail: message, + life: duration || Number(mjrSettings.toasts.duration) || 3000, + closable: true, + }); +} export function mjrAttachHoverFeedback(el, message, delay = 3000) { if (!el) return; diff --git a/js/ui_sidebar.js b/js/ui_sidebar.js index 4a702d56..e38523be 100644 --- a/js/ui_sidebar.js +++ b/js/ui_sidebar.js @@ -383,14 +383,14 @@ export function createMetadataSidebar(options) { saveTagsBtn.style.padding = "2px 8px"; saveTagsBtn.style.borderRadius = "6px"; - const persistTags = async () => { - const newTags = tagsInput.value - .split(",") - .map((t) => t.trim()) - .filter((t) => t.length > 0); - file.tags = newTags; - await updateFileMetadata(file, "Tags updated"); - }; + const persistTags = async () => { + const newTags = tagsInput.value + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0); + file.tags = newTags; + await updateFileMetadata(file, { tags: newTags }, "Tags updated"); + }; saveTagsBtn.addEventListener("click", () => persistTags()); tagsInput.addEventListener("keydown", (e) => { @@ -461,36 +461,40 @@ export function createMetadataSidebar(options) { } } - async function updateFileMetadata(file, toastLabel = "Metadata updated") { - const filename = file.filename || file.name; - if (!filename) { - console.error("Error updating metadata: missing filename"); - return; - } - const subfolder = file.subfolder || ""; - const rating = file.rating || 0; - const tags = Array.isArray(file.tags) ? file.tags : []; - - try { - const res = await fetch("/mjr/filemanager/metadata/update", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - filename, - subfolder, - rating, - tags, - }), - }); - const data = await res.json(); - if (data.ok) { - file.rating = data.rating ?? rating; - file.tags = data.tags ?? tags; - if (!(state.metaCache instanceof Map)) state.metaCache = new Map(); - const key = metadataKey(file); - state.metaCache.delete(key); - if (state.metaFetchAt && typeof state.metaFetchAt.delete === "function") { - state.metaFetchAt.delete(key); + async function updateFileMetadata(file, updates, toastLabel = "Metadata updated") { + const filename = file.filename || file.name; + if (!filename) { + console.error("Error updating metadata: missing filename"); + return; + } + const subfolder = file.subfolder || ""; + const safeUpdates = updates && typeof updates === "object" ? updates : {}; + + try { + const body = { filename, subfolder }; + if (Object.prototype.hasOwnProperty.call(safeUpdates, "rating")) { + body.rating = Number(safeUpdates.rating) || 0; + } + if (Object.prototype.hasOwnProperty.call(safeUpdates, "tags")) { + body.tags = Array.isArray(safeUpdates.tags) ? safeUpdates.tags : []; + } + + const res = await fetch("/mjr/filemanager/metadata/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...body, + }), + }); + const data = await res.json(); + if (data.ok) { + if (data.rating !== undefined) file.rating = data.rating; + if (data.tags !== undefined) file.tags = data.tags; + if (!(state.metaCache instanceof Map)) state.metaCache = new Map(); + const key = metadataKey(file); + state.metaCache.delete(key); + if (state.metaFetchAt && typeof state.metaFetchAt.delete === "function") { + state.metaFetchAt.delete(key); } applyFilterAndRender(); if (state.currentFile === file) { @@ -508,14 +512,14 @@ export function createMetadataSidebar(options) { } } catch (err) { console.error("[Majoor.AssetsManager] metadata update failed", err); - mjrShowToast("error", "Network error while saving metadata", "Error"); - } - } - - function setRating(file, rating) { - file.rating = rating; - updateFileMetadata(file, "Rating updated"); - } + mjrShowToast("error", "Network error while saving metadata", "Error"); + } + } + + function setRating(file, rating) { + file.rating = rating; + updateFileMetadata(file, { rating }, "Rating updated"); + } function setMetadataPanelVisibility(visible) { metaPanel.style.display = visible ? "flex" : "none"; diff --git a/js/ui_viewer.js b/js/ui_viewer.js index 44940970..8b45f2d4 100644 --- a/js/ui_viewer.js +++ b/js/ui_viewer.js @@ -1331,6 +1331,69 @@ export function mjrUpdateViewerRatingDisplay(rating) { } } +export function mjrApplyOpenViewerSettings() { + try { + mjrUpdateNavVisibility(); + } catch (_) {} + + const frame = mjrViewerState.frame; + if (!frame) return; + + // Update video element behavior without rerendering (keeps zoom/pan state) + try { + frame.querySelectorAll("video").forEach((el) => { + el.autoplay = !!mjrSettings.viewer.autoplayVideos; + el.loop = !!mjrSettings.viewer.loopVideos; + el.muted = !!mjrSettings.viewer.muteVideos; + }); + } catch (_) {} + + // Toggle rating HUD (single viewer only) + try { + if (mjrViewerIsAB) return; + const pane = frame.firstChild; + const mediaWrap = pane && pane.firstChild; + if (!mediaWrap || !mediaWrap.appendChild) return; + + if (!mjrSettings.viewer.showRatingHUD) { + if (mjrViewerRatingEl && mjrViewerRatingEl.parentNode) { + mjrViewerRatingEl.parentNode.removeChild(mjrViewerRatingEl); + } + mjrViewerRatingEl = null; + return; + } + + if (!mjrViewerRatingEl) { + const existing = mediaWrap.querySelector?.("[data-mjr-viewer-rating='1']") || null; + if (existing) { + mjrViewerRatingEl = existing; + } else { + const badge = document.createElement("div"); + Object.assign(badge.style, { + position: "absolute", + top: "8px", + left: "8px", + padding: "6px 10px", + borderRadius: "8px", + background: "rgba(0,0,0,0.6)", + color: "#ffd45a", + fontWeight: "700", + letterSpacing: "2px", + fontSize: "20px", + pointerEvents: "none", + }); + badge.dataset.mjrViewerRating = "1"; + mediaWrap.appendChild(badge); + mjrViewerRatingEl = badge; + } + } + + const file = mjrCurrentList[mjrCurrentIndex]; + const currentRating = Math.max(0, Math.min(5, mjrGetRating(file))); + mjrUpdateViewerRatingDisplay(currentRating); + } catch (_) {} +} + async function mjrSetViewerRating(rating) { if (mjrViewerIsAB) return; if (!mjrCurrentList.length) return; @@ -1344,7 +1407,7 @@ export function mjrUpdateViewerRatingDisplay(rating) { const firstInst = mjrGlobalState.instances.values().next().value; if (firstInst && typeof firstInst.updateFileMetadata === "function") { try { - await firstInst.updateFileMetadata(file, "Rating updated"); + await firstInst.updateFileMetadata(file, { rating: r }, "Rating updated"); } catch (err) { console.warn("[Majoor.AssetsManager] viewer rating persist failed", err); } diff --git a/js/viewer/interaction.js b/js/viewer/interaction.js index 6b175465..e22ebb5e 100644 --- a/js/viewer/interaction.js +++ b/js/viewer/interaction.js @@ -7,13 +7,14 @@ export function setupViewerInteractions({ applyCheckerboardBg, mjrSettings, }) { - const allowZoom = kind === "image"; + const allowZoom = kind === "image" || kind === "video"; let scale = 1; const originX = 50; const originY = 50; let offsetX = 0; let offsetY = 0; let resetZoomPan = () => {}; + let minScale = 1; const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); const calcFitScale = () => { @@ -27,8 +28,10 @@ export function setupViewerInteractions({ return Math.max(0.01, Math.min(10, ratio || 1)); }; - if (allowZoom) el.style.transformOrigin = `${originX}% ${originY}%`; - else { + if (allowZoom) { + el.style.transformOrigin = `${originX}% ${originY}%`; + el.style.willChange = "transform"; + } else { zoomHud.style.display = "none"; el.style.transform = "none"; el.style.cursor = "default"; @@ -36,18 +39,19 @@ export function setupViewerInteractions({ const applyTransform = () => { if (!allowZoom) return; - if (scale <= 1) { + if (scale <= minScale) { offsetX = 0; offsetY = 0; } el.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; - el.style.cursor = scale > 1 ? "grab" : "default"; + el.style.cursor = scale > minScale ? "grab" : "default"; zoomHud.textContent = `${Math.round(scale * 100)}%`; }; if (allowZoom) { resetZoomPan = () => { - scale = calcFitScale(); + minScale = calcFitScale(); + scale = minScale; offsetX = 0; offsetY = 0; applyTransform(); @@ -97,7 +101,7 @@ export function setupViewerInteractions({ const stopDrag = () => { if (!dragging) return; dragging = false; - el.style.cursor = scale > 1 ? "grab" : "default"; + el.style.cursor = scale > minScale ? "grab" : "default"; window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("pointerup", stopDrag); window.removeEventListener("pointercancel", stopDrag); @@ -105,7 +109,7 @@ export function setupViewerInteractions({ mediaWrap.addEventListener("pointerdown", (ev) => { if (ev.button !== 0) return; - if (scale <= 1) return; + if (scale <= minScale) return; ev.preventDefault(); try { mediaWrap.setPointerCapture(ev.pointerId); @@ -145,7 +149,7 @@ export function setupViewerInteractions({ textAlign: "center", whiteSpace: "nowrap", }); - hint.textContent = "Scroll: Zoom • Drag: Pan • F: Fit/Reset"; + hint.textContent = "Scroll: Zoom • Drag (zoomed): Pan • F: Fit/Reset"; mediaWrap.appendChild(hint); hint.style.transition = "opacity 0.3s ease"; let hideTimer = setTimeout(() => { diff --git a/server/utils.py b/server/utils.py index 904634d2..f4c810be 100644 --- a/server/utils.py +++ b/server/utils.py @@ -1,6 +1,7 @@ import json import os import platform +import re import shutil import subprocess import unicodedata @@ -138,6 +139,55 @@ def _parse_rating_value(raw) -> int: return int(round(min(numeric, 5))) +def _coerce_rating_to_stars(raw) -> int: + """ + Normalize arbitrary rating inputs to 0..5 stars. + Accepts: + - 0..5 stars (int/float/str) + - Windows percent scale (0..100) + - star glyphs (e.g. ★★★☆☆) + """ + if raw in (None, ""): + return 0 + try: + num = float(str(raw).strip().replace(",", ".").split()[0]) + except Exception: + return max(0, min(_parse_rating_value(raw), 5)) + + if num <= 0: + return 0 + if 0 <= num <= 5: + return max(0, min(int(round(num)), 5)) + if num <= 100: + return windows_percent_to_stars(int(round(num))) + return max(0, min(_parse_rating_value(raw), 5)) + + +def _normalize_tags(raw) -> List[str]: + """ + Normalize tags to a deduplicated, order-preserving list of non-empty strings. + Accepts list or comma/semicolon-delimited strings. + """ + if raw is None: + return [] + if isinstance(raw, list): + candidates = raw + elif isinstance(raw, str): + candidates = [t.strip() for t in re.split(r"[;,]", raw)] + else: + return [] + + out: List[str] = [] + seen = set() + for t in candidates: + s = str(t).strip() + if not s or s in seen: + continue + seen.add(s) + out.append(s) + return out + + def rating_to_windows_percent(stars: int) -> int: """ Map 0..5 stars to Windows percent scale expected by SharedUserRating. @@ -373,7 +423,7 @@ def set_exif_metadata(file_path: str, rating: int, tags: list) -> bool: try: write_timeout_s = _env_timeout("MJR_EXIFTOOL_WRITE_TIMEOUT", 3.0, min_s=0.5, max_s=120.0) - r = max(0, min(int(rating), 5)) + r = _coerce_rating_to_stars(rating) win_r = rating_to_windows_percent(r) is_video = file_path.lower().endswith((".mp4", ".mov", ".m4v", ".webm", ".mkv")) @@ -394,7 +444,7 @@ def set_exif_metadata(file_path: str, rating: int, tags: list) -> bool: cat = tags_to_windows_category(tags) cmd.append(f"-Microsoft:Category={cat}") - clean_tags = [str(t).strip() for t in tags if str(t).strip()] + clean_tags = _normalize_tags(tags) # Always write Category for video, even if empty, to clear stale values if is_video and not clean_tags: cmd.append(f"-Microsoft:Category=") @@ -435,33 +485,43 @@ def set_windows_metadata(file_path: str, rating: int, tags: list) -> bool: if not win32com: return False + original_mtime = _get_mtime_safe(file_path) shell = win32com.Dispatch("Shell.Application") folder = shell.Namespace(os.path.dirname(file_path)) file = folder.ParseName(os.path.basename(file_path)) rating_idx, tags_idx = _resolve_shell_indices(folder) - # Rating (scale 1-99); Windows 5-star uses 1-5, but accepting broader range + # Rating (SharedUserRating percent 0..99); accept stars or percent and normalize if rating is not None: - try: - r_int = int(rating) - except Exception: - r_int = 0 - # Accept 0-5 stars or already-percent; normalize to Windows percent scale - if 0 <= r_int <= 5: - rating_val = rating_to_windows_percent(r_int) - else: - rating_val = max(0, min(99, r_int)) + stars = _coerce_rating_to_stars(rating) + rating_val = rating_to_windows_percent(stars) folder.GetDetailsOf(file, rating_idx) # force index load folder.SetDetailsOf(file, rating_idx, str(rating_val)) - if tags: + tags_list = _normalize_tags(tags) + if tags_list: folder.GetDetailsOf(file, tags_idx) - folder.SetDetailsOf(file, tags_idx, "; ".join(tags)) + folder.SetDetailsOf(file, tags_idx, "; ".join(tags_list)) elif tags == []: folder.GetDetailsOf(file, tags_idx) folder.SetDetailsOf(file, tags_idx, "") + # Restore mtime to avoid reordering the grid after rating/tag updates + if original_mtime is not None: + try: + os.utime(file_path, (original_mtime, original_mtime)) + except Exception: + pass + + global _CACHE_EPOCH + _CACHE_EPOCH += 1 + epoch = _CACHE_EPOCH + _META_CACHE[file_path] = ( + original_mtime if original_mtime is not None else _get_mtime_safe(file_path), + epoch, + {"rating": _coerce_rating_to_stars(rating), "tags": tags_list}, + ) return True except Exception: return False @@ -524,8 +584,7 @@ def update_metadata_with_windows(file_path: str, updates: dict) -> dict: Returns final metadata. """ global _CACHE_EPOCH - _CACHE_EPOCH += 1 - epoch = _CACHE_EPOCH + start_epoch = _CACHE_EPOCH # Load current values (prefer system metadata, fallback to sidecar) sys_meta = get_system_metadata(file_path) or {} @@ -539,11 +598,11 @@ def update_metadata_with_windows(file_path: str, updates: dict) -> dict: if not has_rating and not has_tags: return {"rating": current_rating, "tags": current_tags} - rating = updates.get("rating", current_rating if has_rating else current_rating) - tags = updates.get("tags", current_tags if has_tags else current_tags) + rating = _coerce_rating_to_stars(updates.get("rating", current_rating)) + tags = _normalize_tags(updates.get("tags", current_tags)) # Only write tags when provided; otherwise preserve existing tags in system metadata - target_tags = tags if has_tags else current_tags + target_tags = tags if has_tags else _normalize_tags(current_tags) ok_win = False ok_exif = False @@ -551,12 +610,22 @@ def update_metadata_with_windows(file_path: str, updates: dict) -> dict: ok_win = set_windows_metadata(file_path, rating if has_rating else current_rating, target_tags if has_tags else current_tags) ok_exif = set_exif_metadata(file_path, rating if has_rating else current_rating, target_tags if has_tags else current_tags) + sidecar_saved = False if _sidecar_allowed_for(file_path): save_metadata(file_path, {"rating": rating if has_rating else current_rating, "tags": target_tags}) + sidecar_saved = True - effective_rating = (rating if has_rating else current_rating) if (ok_win or ok_exif) else current_rating + effective_rating = (rating if has_rating else current_rating) if (ok_win or ok_exif or sidecar_saved) else current_rating effective_tags = target_tags - _META_CACHE[file_path] = (_get_mtime_safe(file_path), epoch, {"rating": effective_rating, "tags": effective_tags}) + + # Ensure cache invalidates even when mtime is preserved (Windows/ExifTool writes). + if (ok_win or ok_exif or sidecar_saved) and _CACHE_EPOCH == start_epoch: + _CACHE_EPOCH += 1 + _META_CACHE[file_path] = ( + _get_mtime_safe(file_path), + _CACHE_EPOCH, + {"rating": _coerce_rating_to_stars(effective_rating), "tags": _normalize_tags(effective_tags)}, + ) return {"rating": effective_rating, "tags": effective_tags} @@ -565,26 +634,19 @@ def apply_system_metadata(file_path: str, rating, tags: list) -> dict: Try exiftool first (if installed), otherwise Windows Shell. """ global _CACHE_EPOCH - _CACHE_EPOCH += 1 - epoch = _CACHE_EPOCH - try: - rating_int = int(rating) if rating is not None else 0 - except Exception: - rating_int = 0 - tags_list: List[str] = [] - if isinstance(tags, list): - tags_list = [str(t) for t in tags if str(t).strip()] + start_epoch = _CACHE_EPOCH + rating_int = _coerce_rating_to_stars(rating) + tags_list = _normalize_tags(tags) # exiftool (meilleure compat Windows + autres OS) if set_exif_metadata(file_path, rating_int, tags_list): - mtime = _get_mtime_safe(file_path) - _META_CACHE[file_path] = (mtime, epoch, {"rating": rating_int, "tags": tags_list}) return {"rating": rating_int, "tags": tags_list} # fallback shell API meta = apply_windows_metadata(file_path, rating_int, tags_list) - mtime = _get_mtime_safe(file_path) - _META_CACHE[file_path] = (mtime, epoch, meta) + if _CACHE_EPOCH == start_epoch: + _CACHE_EPOCH += 1 + _META_CACHE[file_path] = (_get_mtime_safe(file_path), _CACHE_EPOCH, meta) return meta