Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions Jellyfin.Plugin.Streamyfin/Pages/YamlEditor/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,118 @@ export default function (view, params) {
const Page = {
editor: null,
yaml: null,
parentIdProvider: null,
parentIdSuggestions: null,
// Fetch libraries and collections from Jellyfin and map to Monaco suggestions
loadParentIdSuggestions: async function () {
try {
const userId = await window.ApiClient.getCurrentUserId?.() ?? null;

// Build URLs using ApiClient to preserve base path and auth
// Prefer user views over raw media folders for broader compatibility
const libsUrl = userId
? window.ApiClient.getUrl(`Users/${userId}/Views`)
: window.ApiClient.getUrl('Library/MediaFolders');
Comment on lines +27 to +33
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make await ?? precedence explicit and allow retries after load failure

  • Parenthesize await … ?? null to avoid precedence gotchas.
  • On fetch failure you set parentIdSuggestions = []. That prevents future retries in the same session because the provider checks only for Array-ness. Use undefined to re-attempt later.
-                        const userId = await window.ApiClient.getCurrentUserId?.() ?? null;
+                        const userId = (await window.ApiClient.getCurrentUserId?.()) ?? null;
@@
-                        console.warn('Failed to load parentId suggestions', e);
-                        Page.parentIdSuggestions = [];
+                        console.warn('Failed to load parentId suggestions', e);
+                        // unset to allow retry on next invocation
+                        Page.parentIdSuggestions = undefined;

Also applies to: 80-83

🤖 Prompt for AI Agents
In Jellyfin.Plugin.Streamyfin/Pages/YamlEditor/index.js around lines 27-33 (and
also apply the same change at lines 80-83), make the await/nullish-coalescing
precedence explicit by parenthesizing the awaited call so the nullish coalescing
applies to the awaited result (i.e., wrap the await expression in parentheses
before applying ?? null), and when a fetch fails do not set parentIdSuggestions
to an empty array (which prevents retries) but set it to undefined so the
provider can attempt loading again later; apply both changes in the two
mentioned locations.

const collectionsUrl = userId
? window.ApiClient.getUrl(`Users/${userId}/Items`, {
IncludeItemTypes: 'BoxSet',
Recursive: true,
SortBy: 'SortName',
SortOrder: 'Ascending'
})
: null;

// Fetch in parallel using ApiClient.ajax to include auth
const [libsRes, colRes] = await Promise.all([
window.ApiClient.ajax({ type: 'GET', url: libsUrl, contentType: 'application/json' }),
collectionsUrl
? window.ApiClient.ajax({ type: 'GET', url: collectionsUrl, contentType: 'application/json' })
: Promise.resolve(null)
]);

const libsJson = libsRes ? libsRes : { Items: [] };
const colsJson = colRes ? colRes : { Items: [] };

// Normalize arrays (Jellyfin usually returns { Items: [...] })
const libraries = Array.isArray(libsJson?.Items) ? libsJson.Items : (Array.isArray(libsJson) ? libsJson : []);
const collections = Array.isArray(colsJson?.Items) ? colsJson.Items : (Array.isArray(colsJson) ? colsJson : []);

const libSuggestions = libraries
.filter(i => i?.Id && i?.Name)
.map(i => ({
label: `${i.Name} (${i.Id})`,
kind: monaco.languages.CompletionItemKind.Value,
insertText: i.Id,
detail: 'Library folder',
documentation: i.Path ? `Path: ${i.Path}` : undefined
}));

const colSuggestions = collections
.filter(i => i?.Id && i?.Name)
.map(i => ({
label: `${i.Name} (${i.Id})`,
kind: monaco.languages.CompletionItemKind.Value,
insertText: i.Id,
detail: 'Collection',
documentation: i.Overview || undefined
}));

Page.parentIdSuggestions = [...libSuggestions, ...colSuggestions];
} catch (e) {
console.warn('Failed to load parentId suggestions', e);
Page.parentIdSuggestions = [];
}
},
// Register a YAML completion provider that triggers when value for key 'parentId' is being edited
registerParentIdProvider: function () {
if (Page.parentIdProvider) return; // avoid duplicates

Page.parentIdProvider = monaco.languages.registerCompletionItemProvider('yaml', {
triggerCharacters: [':', ' ', '-', '\n', '"', "'"],
provideCompletionItems: async (model, position) => {
try {
const line = model.getLineContent(position.lineNumber);
const beforeCursor = line.substring(0, position.column - 1);
// Heuristic: we're in a value position for a key named 'parentId'
// Match lines like: "parentId: |" or "id: |" with optional indent or list dash
const isTargetLine = /(^|\s|-)\b(parentId|id)\b\s*:\s*[^#]*$/i.test(beforeCursor);
if (!isTargetLine) {
return { suggestions: [] };
}

if (!Array.isArray(Page.parentIdSuggestions)) {
await Page.loadParentIdSuggestions();
}

// Compute replacement range: from word start to cursor
const word = model.getWordUntilPosition(position);
const startColFromColon = (() => {
const idx = beforeCursor.lastIndexOf(':');
if (idx === -1) return word.startColumn;
let start = idx + 1; // first char after colon
// skip spaces
while (start < beforeCursor.length && beforeCursor.charAt(start) === ' ') start++;
// skip optional opening quotes
while (start < beforeCursor.length && (beforeCursor.charAt(start) === '"' || beforeCursor.charAt(start) === "'")) start++;
// Monaco columns are 1-based
return start + 1;
})();
const range = new monaco.Range(
position.lineNumber,
Math.max(1, startColFromColon),
position.lineNumber,
position.column
);

const suggestions = Page.parentIdSuggestions.map(s => ({ ...s, range }));
return { suggestions };
} catch (err) {
console.warn('parentId provider error', err);
return { suggestions: [] };
}
}
});
},
saveConfig: function (e) {
e.preventDefault();
shared.setYamlConfig(Page.editor.getModel().getValue())
Expand Down Expand Up @@ -76,6 +188,9 @@ export default function (view, params) {
saveBtn().addEventListener("click", Page.saveConfig);
exampleBtn().addEventListener("click", Page.resetConfig);

// Register dynamic intellisense for parentId values
Page.registerParentIdProvider();

if (shared.getConfig() && Page.editor == null) {
Page.loadConfig(shared.getConfig());
}
Expand All @@ -102,8 +217,10 @@ export default function (view, params) {
console.log("Hiding")
Page?.editor?.dispose()
Page?.yaml?.dispose()
Page?.parentIdProvider?.dispose?.()
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The optional chaining on dispose suggests uncertainty about the method's existence. Consider checking if dispose is a function before calling it: if (typeof Page?.parentIdProvider?.dispose === 'function') Page.parentIdProvider.dispose()

Suggested change
Page?.parentIdProvider?.dispose?.()
if (typeof Page?.parentIdProvider?.dispose === 'function') Page.parentIdProvider.dispose();

Copilot uses AI. Check for mistakes.
Page.editor = undefined;
Page.yaml = undefined;
Page.parentIdProvider = undefined;
monaco?.editor?.getModels?.()?.forEach(model => model.dispose())
monaco?.editor?.getEditors?.()?.forEach(editor => editor.dispose());
});
Expand Down