Skip to content

Commit ea65b4c

Browse files
Harden smart detect for mixed workloads
1 parent cac6207 commit ea65b4c

7 files changed

Lines changed: 484 additions & 41 deletions

File tree

archive/folderview.plus-2026.03.21.11.txz.sha256

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
5bf102e09c795330f0fd71dde80e5846fc795a17e8ee7069367993ab5bc15556 folderview.plus-2026.03.21.40.txz

folderview.plus.plg

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@
66
<!ENTITY launch "Settings/FolderViewPlus">
77
<!ENTITY plugdir "/usr/local/emhttp/plugins/&name;">
88
<!ENTITY pluginURL "https://raw.githubusercontent.com/&github;/dev/folderview.plus.plg">
9-
<!ENTITY version "2026.03.21.39">
10-
<!ENTITY md5 "b3503abd315c50f6a67443e14ef00a0a">
9+
<!ENTITY version "2026.03.21.40">
10+
<!ENTITY md5 "2d30f6054962dcf9c49c8c5e777e5608">
1111
]>
1212

1313
<PLUGIN name="&name;" author="&author;" version="&version;" launch="&launch;" pluginURL="&pluginURL;" icon="folder-icon.png" support="https://forums.unraid.net/topic/197631-plugin-folderview-plus/" min="7.0.0">
1414
<CHANGES>
1515

16+
###2026.03.21.40
17+
- Fix: Expanded smart-detect coverage for Docker starter folders so more real-world app families are recognized and auto-assigned during wizard onboarding.
18+
- Quality: Smart category selection now uses richer runtime signals, including compose stacks, template metadata, bind paths, project/support URLs, and broader fuzzy token matching.
19+
- Regression guard: Added coverage for mixed real-world workloads so media, gaming, cloud, notification, security, monitoring, and utility apps stay classified correctly.
20+
21+
1622
###2026.03.21.39
1723
- Fix: Improved setup wizard smart-detect matching so more Docker containers are auto-assigned into generated starter folders during new-install onboarding.
1824
- Quality: Smart-detect now scores compose-project names, Docker template metadata, project/support URLs, and richer runtime signals instead of relying on basic name/image tokens alone.

src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.js

Lines changed: 195 additions & 26 deletions
Large diffs are not rendered by default.

src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.wizard.js

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,42 @@ const normalizeSetupAssistantMatchText = (value) => (
687687
.trim()
688688
);
689689

690+
const SETUP_ASSISTANT_TEMPLATE_FALLBACK_BY_TYPE = Object.freeze({
691+
docker: 'Utilities',
692+
vm: 'Utility VMs'
693+
});
694+
695+
const getSetupAssistantBlueprintHeuristicMap = (type, blueprintName) => {
696+
const resolvedType = normalizeManagedType(type);
697+
const normalizedName = normalizeSetupAssistantMatchText(blueprintName).replace(/\s+/g, '-');
698+
if (resolvedType === 'docker') {
699+
const dockerMap = {
700+
media: { contains: ['seerr', 'wizarr', 'listenarr', 'cleanuparr', 'agregarr', 'watch', 'request', 'discover'], pathContains: ['media', 'movies', 'shows', 'tv', 'music', 'books', 'audiobooks', 'anime', 'comics', 'photos'] },
701+
downloads: { contains: ['download', 'torrent', 'nzb', 'slsk', 'seed'] },
702+
monitoring: { contains: ['myspeed', 'speedtest', 'latency', 'uptime', 'metrics', 'telemetry'] },
703+
'cloud-&-sync': { contains: ['nextcloud', 'owncloud', 'seafile', 'cloud', 'sync', 'drive', 'collabora', 'onlyoffice'] },
704+
notifications: { contains: ['notify', 'notification', 'ntfy', 'gotify', 'apprise', 'notifiarr', 'pushover', 'webhook'] },
705+
utilities: { contains: ['qdirstat', 'diskspeed', 'ncdu', 'baobab', 'icons', 'icon', 'tool', 'utility', 'manager'], pathContains: ['appdata', 'storage', 'tools'] },
706+
automation: { contains: ['homeassistant', 'node red', 'n8n', 'mqtt', 'esphome', 'zigbee', 'zwave'] },
707+
database: { contains: ['postgres', 'mysql', 'mariadb', 'mongo', 'redis', 'database', 'db'] },
708+
security: { contains: ['clamav', 'antivirus', 'vaultwarden', 'authentik', 'authelia', 'crowdsec', 'fail2ban', 'security'] },
709+
development: { contains: ['git', 'code', 'dev', 'build', 'registry', 'runner', 'vscode'] },
710+
'ci-cd': { contains: ['jenkins', 'runner', 'drone', 'argocd', 'ci', 'cd'] },
711+
gaming: { contains: ['crafty', 'minecraft', 'palworld', 'valheim', 'satisfactory', 'steam', 'gameserver', 'server'] },
712+
'game-servers': { contains: ['crafty', 'pterodactyl', 'pelican', 'steamcmd', 'minecraft', 'palworld', 'valheim', 'satisfactory', 'terraria', 'enshrouded', 'gameserver', 'server'] }
713+
};
714+
return dockerMap[normalizedName] || null;
715+
}
716+
const vmMap = {
717+
'utility-vms': { contains: ['utility', 'tools', 'helper', 'management', 'admin'] },
718+
'network-vms': { contains: ['router', 'firewall', 'pfsense', 'opnsense', 'dns', 'proxy'] },
719+
'security-vms': { contains: ['security', 'siem', 'wazuh', 'ids', 'ips', 'firewall'] },
720+
'desktop-vms': { contains: ['desktop', 'workstation', 'windows', 'ubuntu', 'macos'] },
721+
'gaming-vms': { contains: ['gaming', 'steam', 'parsec', 'moonlight', 'sunshine', 'gpu'] }
722+
};
723+
return vmMap[normalizedName] || null;
724+
};
725+
690726
const collectSetupAssistantItemMatchProfile = (type, itemName, itemInfo) => {
691727
const resolvedType = normalizeManagedType(type);
692728
const tokenSet = new Set();
@@ -723,6 +759,12 @@ const collectSetupAssistantItemMatchProfile = (type, itemName, itemInfo) => {
723759
addTokens(itemInfo?.info?.Support);
724760
addTokens(itemInfo?.info?.ReadMe);
725761
addTokens(itemInfo?.info?.template?.path);
762+
(Array.isArray(itemInfo?.info?.HostConfig?.Binds) ? itemInfo.info.HostConfig.Binds : []).forEach((bind) => addTokens(bind));
763+
(Array.isArray(itemInfo?.Mounts) ? itemInfo.Mounts : []).concat(Array.isArray(itemInfo?.info?.Mounts) ? itemInfo.info.Mounts : []).forEach((mount) => {
764+
addTokens(mount?.Source);
765+
addTokens(mount?.Destination);
766+
addTokens(mount?.Name);
767+
});
726768
if (utils && typeof utils.getComposeProjectFromLabels === 'function') {
727769
addTokens(utils.getComposeProjectFromLabels(labels));
728770
}
@@ -743,7 +785,7 @@ const collectSetupAssistantItemMatchProfile = (type, itemName, itemInfo) => {
743785
};
744786
};
745787

746-
const scoreSetupAssistantTemplateMatch = (profile, blueprint) => {
788+
const scoreSetupAssistantTemplateMatch = (profile, blueprint, type = 'docker') => {
747789
const tokens = profile instanceof Set ? profile : profile?.tokens;
748790
const phrases = profile?.phrases instanceof Set ? profile.phrases : new Set();
749791
const normalizedText = String(profile?.normalizedText || '').trim();
@@ -773,6 +815,15 @@ const scoreSetupAssistantTemplateMatch = (profile, blueprint) => {
773815
parts.forEach((part) => {
774816
if (tokens.has(part)) {
775817
matchedParts += 1;
818+
return;
819+
}
820+
if (part.length >= 4) {
821+
for (const token of tokens) {
822+
if (token.includes(part) || part.includes(token)) {
823+
matchedParts += 1;
824+
break;
825+
}
826+
}
776827
}
777828
});
778829
if (matchedParts === parts.length) {
@@ -782,8 +833,50 @@ const scoreSetupAssistantTemplateMatch = (profile, blueprint) => {
782833
}
783834
score += keywordScore;
784835
};
836+
const hasTokenEndingWith = (suffix) => {
837+
const normalizedSuffix = String(suffix || '').trim().toLowerCase();
838+
if (!normalizedSuffix) {
839+
return false;
840+
}
841+
for (const token of tokens) {
842+
if (token.endsWith(normalizedSuffix)) {
843+
return true;
844+
}
845+
}
846+
return false;
847+
};
848+
const hasTokenContainingAny = (values = []) => {
849+
const safeValues = Array.isArray(values) ? values : [];
850+
for (const rawValue of safeValues) {
851+
const value = normalizeSetupAssistantMatchText(rawValue);
852+
if (!value) {
853+
continue;
854+
}
855+
for (const token of tokens) {
856+
if (token === value || token.includes(value) || value.includes(token)) {
857+
return true;
858+
}
859+
}
860+
if (normalizedText.includes(value)) {
861+
return true;
862+
}
863+
}
864+
return false;
865+
};
785866
detectKeywords.forEach((keyword) => consumeKeyword(keyword, 1));
786867
consumeKeyword(blueprint.name, 0.6);
868+
const heuristic = getSetupAssistantBlueprintHeuristicMap(type, blueprint?.name || '');
869+
if (heuristic) {
870+
if (normalizeSetupAssistantMatchText(blueprint?.name).replace(/\s+/g, '-') === 'media' && hasTokenEndingWith('arr')) {
871+
score += 12;
872+
}
873+
if (hasTokenContainingAny(heuristic.contains)) {
874+
score += 10;
875+
}
876+
if (hasTokenContainingAny(heuristic.pathContains)) {
877+
score += 6;
878+
}
879+
}
787880
return score;
788881
};
789882

@@ -799,6 +892,8 @@ const buildSetupAssistantTemplateAssignmentPreview = (type, selectedBlueprints)
799892
assignedByTemplate[name] = [];
800893
}
801894
});
895+
const fallbackTemplateName = String(SETUP_ASSISTANT_TEMPLATE_FALLBACK_BY_TYPE[resolvedType] || '').trim();
896+
const fallbackBlueprint = blueprints.find((blueprint) => String(blueprint?.name || '').trim() === fallbackTemplateName) || null;
802897

803898
let matched = 0;
804899
let unmatched = 0;
@@ -807,12 +902,16 @@ const buildSetupAssistantTemplateAssignmentPreview = (type, selectedBlueprints)
807902
let bestBlueprint = null;
808903
let bestScore = 0;
809904
blueprints.forEach((blueprint) => {
810-
const score = scoreSetupAssistantTemplateMatch(profile, blueprint);
905+
const score = scoreSetupAssistantTemplateMatch(profile, blueprint, resolvedType);
811906
if (score > bestScore) {
812907
bestScore = score;
813908
bestBlueprint = blueprint;
814909
}
815910
});
911+
if ((!bestBlueprint || bestScore < 4) && fallbackBlueprint) {
912+
bestBlueprint = fallbackBlueprint;
913+
bestScore = 4;
914+
}
816915
if (!bestBlueprint || bestScore < 4) {
817916
unmatched += 1;
818917
return;
@@ -842,7 +941,18 @@ const buildSetupAssistantTemplatePlanForType = (type) => {
842941
const bootstrap = getSetupAssistantTemplateBootstrap(resolvedType);
843942
const categoryState = getSetupAssistantTemplateCategoryState(resolvedType);
844943
const selectedNames = new Set(serializeSetupAssistantTemplateSelections(bootstrap.selectedTemplateNames));
845-
const selectedBlueprints = categoryState.blueprints.filter((entry) => selectedNames.has(String(entry?.name || '').trim()));
944+
let selectedBlueprints = categoryState.blueprints.filter((entry) => selectedNames.has(String(entry?.name || '').trim()));
945+
if (bootstrap.enabled === true && bootstrap.autoAssignExisting === true && bootstrap.category === 'smart' && selectedBlueprints.length > 0) {
946+
const fallbackName = String(SETUP_ASSISTANT_TEMPLATE_FALLBACK_BY_TYPE[resolvedType] || '').trim();
947+
const fallbackBlueprint = categoryState.blueprints.find((entry) => String(entry?.name || '').trim() === fallbackName) || null;
948+
const hasFallbackSelected = fallbackBlueprint && selectedBlueprints.some((entry) => String(entry?.name || '').trim() === fallbackName);
949+
if (fallbackBlueprint && !hasFallbackSelected) {
950+
const previewWithoutFallback = buildSetupAssistantTemplateAssignmentPreview(resolvedType, selectedBlueprints);
951+
if ((previewWithoutFallback.unmatched || 0) > 0) {
952+
selectedBlueprints = [...selectedBlueprints, fallbackBlueprint];
953+
}
954+
}
955+
}
846956
const existingFolders = getFolderMap(resolvedType);
847957
const existingNameSet = new Set(
848958
Object.values(existingFolders || {})

0 commit comments

Comments
 (0)