Skip to content
Open
Show file tree
Hide file tree
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
128 changes: 128 additions & 0 deletions src/blueprints/playlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,134 @@ def delete_playlist(playlist_name):

return jsonify({"success": True, "message": f"Deleted playlist '{playlist_name}'!"})


@playlist_bp.route('/rename_plugin_instance', methods=['PUT'])
def rename_plugin_instance():
"""Rename a plugin instance within a playlist.

Expects JSON: { playlist_name, plugin_id, old_name, new_name }
"""
device_config = current_app.config['DEVICE_CONFIG']
playlist_manager = device_config.get_playlist_manager()

data = request.get_json() or {}
playlist_name = data.get('playlist_name')
plugin_id = data.get('plugin_id')
old_name = data.get('old_name')
new_name = data.get('new_name')

if not playlist_name or not plugin_id or not old_name or not new_name:
return jsonify({"error": "Missing required fields"}), 400

# basic validation: allow alphanumeric, spaces (Unicode-aware)
try:
import unicodedata
def _normalize(s):
return unicodedata.normalize('NFC', (s or "").strip())
except Exception:
def _normalize(s):
return (s or "").strip()

if not all((ch.isalpha() or ch.isspace() or ch.isnumeric()) for ch in _normalize(new_name)):
return jsonify({"error": "Instance name can only contain alphanumeric characters and spaces"}), 400

playlist = playlist_manager.get_playlist(playlist_name)
if not playlist:
return jsonify({"error": "Playlist not found"}), 400

# Find plugin instances using normalized comparison to handle accented characters
def _find_plugin(playlist_obj, plugin_id_val, name_val):
for p in playlist_obj.plugins:
if p.plugin_id == plugin_id_val and _normalize(p.name) == _normalize(name_val):
return p
return None

existing = _find_plugin(playlist, plugin_id, new_name)
plugin_instance = _find_plugin(playlist, plugin_id, old_name)

# Add diacritics-insensitive fallback matching for old instance name
def _remove_diacritics(s):
try:
import unicodedata
nkfd = unicodedata.normalize('NFKD', s)
return ''.join(ch for ch in nkfd if not unicodedata.combining(ch))
except Exception:
return s

if not plugin_instance:
base_old = _remove_diacritics(_normalize(old_name)).lower()
candidates = [p for p in playlist.plugins if p.plugin_id == plugin_id and _remove_diacritics(_normalize(p.name)).lower() == base_old]
if len(candidates) == 1:
plugin_instance = candidates[0]
elif len(candidates) > 1:
# Ambiguous match: do not guess — return an error listing matches
matched = [p.name for p in candidates]
return jsonify({
"error": f"Ambiguous plugin instance name '{old_name}'",
"matches": matched
}), 400

if not plugin_instance:
# collect existing instance names for this plugin to help debugging
existing_names = [p.name for p in playlist.plugins if p.plugin_id == plugin_id]
return jsonify({"error": f"Plugin instance '{old_name}' not found", "existing_instances": existing_names}), 400

# If an existing instance with the new name exists and it's not the same instance, reject
if existing and existing is not plugin_instance:
return jsonify({"error": f"Plugin instance '{new_name}' already exists"}), 400

Comment thread
saulob marked this conversation as resolved.
# Also enforce global uniqueness across all playlists, consistent with add_plugin / plugin_page
global_existing = playlist_manager.find_plugin(plugin_id, new_name)
if global_existing and global_existing is not plugin_instance:
return jsonify({"error": f"Plugin instance '{new_name}' already exists"}), 400

# rename image file if present — perform filesystem update first, then persist
try:
old_image = plugin_instance.get_image_path()

# compute new image path without mutating the persistent object yet
original_name = plugin_instance.name
try:
plugin_instance.name = new_name
new_image = plugin_instance.get_image_path()
finally:
# revert to original in-memory name until filesystem operations succeed
plugin_instance.name = original_name

plugin_image_dir = device_config.plugin_image_dir
old_path = os.path.join(plugin_image_dir, old_image)
new_path = os.path.join(plugin_image_dir, new_image)

if os.path.exists(old_path):
# fail early if target already exists to avoid overwriting
if os.path.exists(new_path):
return jsonify({"error": f"Target image file already exists: {new_image}"}), 400

try:
os.rename(old_path, new_path)
except OSError as e:
# handle cross-device rename by copying then removing
import errno, shutil
if getattr(e, 'errno', None) == errno.EXDEV:
try:
shutil.copy2(old_path, new_path)
os.remove(old_path)
except Exception:
logger.exception(f"Failed to copy plugin image {old_path} -> {new_path}")
return jsonify({"error": "Failed to rename plugin image file"}), 500
else:
logger.exception(f"Failed to rename plugin image {old_path} -> {new_path}")
return jsonify({"error": "Failed to rename plugin image file"}), 500

# filesystem update succeeded (or there was no image) — now update in-memory name and persist
plugin_instance.name = new_name
device_config.write_config()
except Exception as e:
logger.exception("EXCEPTION CAUGHT: " + str(e))
return jsonify({"error": f"An error occurred: {str(e)}"}), 500

return jsonify({"success": True, "message": f"Renamed '{old_name}' -> '{new_name}'"})

@playlist_bp.app_template_filter('format_relative_time')
def format_relative_time(iso_date_string):
# Parse the input ISO date string
Expand Down
Binary file added src/static/icons/edit_pencil.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
137 changes: 134 additions & 3 deletions src/templates/playlist.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,46 @@
border-color: var(--accent-primary);
}

/* Plugin row hover effect */
.plugin-item {
transition: background-color 0.15s ease;
}

.plugin-item:hover {
background-color: #f2f2f2;
}
/* Inline edit styling */
.plugin-instance.editable {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
/* make button look like plain inline text */
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
color: inherit;
}

.plugin-instance.editable:hover {
text-decoration: underline;
}

.plugin-instance.editable .instance-edit-icon {
width: 20px;
height: 20px;
opacity: 0.35;
transition: opacity 0.2s ease;
vertical-align: middle;
flex-shrink: 0;
}

.plugin-instance.editable:hover .instance-edit-icon {
opacity: 1;
}

/* Thumbnail Preview Modal */
.thumbnail-preview-modal {
display: none;
Expand Down Expand Up @@ -133,6 +173,95 @@
modal.style.display = 'block';
}

// Inline edit for plugin instance name
function enableInlineEdit(el) {
const rawOldName = el.textContent.trim();
const playlistName = el.dataset.playlist;
const pluginId = el.dataset.pluginId;
let isSubmitting = false;
let isClosed = false;

// create input
const input = document.createElement('input');
input.type = 'text';
input.className = 'inline-edit-input';
input.value = rawOldName;
input.style.minWidth = '120px';

// replace element with input
el.style.display = 'none';
el.parentElement.insertBefore(input, el.nextSibling);
input.focus();

function cancel() {
if (isClosed) {
return;
}
isClosed = true;
input.remove();
el.style.display = '';
}

function _normalizeJS(s) {
if (!s) return '';
if (String.prototype.normalize) return s.normalize('NFC');
return s;
}

async function save() {
if (isSubmitting || isClosed) {
return;
}

const rawNew = input.value.trim();
const oldName = _normalizeJS(rawOldName);
const newName = _normalizeJS(rawNew);

if (!newName || newName === oldName) {
cancel();
return;
}

isSubmitting = true;

try {
const resp = await fetch("{{ url_for('playlist.rename_plugin_instance') }}", {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playlist_name: playlistName, plugin_id: pluginId, old_name: oldName, new_name: newName })
});
const result = await resp.json();
if (resp.ok) {
// reload the page to avoid leaving any controls with stale server-rendered attributes
sessionStorage.setItem("storedMessage", JSON.stringify({ type: "success", text: result.message || 'Renamed' }));
location.reload();
} else {
// include server diagnostic info if present
const diag = result && result.existing_instances ? ` Existing: ${result.existing_instances.join(', ')}` : '';
showResponseModal('failure', (result.error || 'Failed to rename') + diag);
cancel();
}
} catch (err) {
console.error(err);
showResponseModal('failure', 'An error occurred');
cancel();
} finally {
isSubmitting = false;
}
}

input.addEventListener('blur', save);
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
save();
} else if (e.key === 'Escape') {
e.preventDefault();
cancel();
}
});
}

function closeThumbnailPreview() {
const modal = document.getElementById('thumbnailPreviewModal');
modal.style.display = 'none';
Expand Down Expand Up @@ -403,15 +532,17 @@ <h1 class="app-title">Playlists</h1>
<div class="playlist-item {% if playlist.name == playlist_config.active_playlist %}active{% endif %}">
<div class="playlist-header">
<span class="playlist-title">{{ playlist.name }}</span>
<button class="edit-button" onclick="openEditModal('{{ playlist.name }}', '{{ playlist.start_time }}','{{ playlist.end_time }}')"><img src="{{ url_for('static', filename='icons/settings.png') }}" alt="edit playlist" class="action-icon"></button>
<button class="edit-button" onclick="openEditModal('{{ playlist.name }}', '{{ playlist.start_time }}','{{ playlist.end_time }}')" title="Edit Playlist">
<img src="{{ url_for('static', filename='icons/settings.png') }}" alt="edit playlist" class="action-icon">
</button>
</div>
<!-- Plugin List -->
<div class="plugin-list">
{% for plugin_instance in playlist.plugins %}
<div class="plugin-item">
<div class="plugin-info" id="{{plugin_instance.name}}">
<img src="{{ url_for('plugin.image', plugin_id=plugin_instance.plugin_id, filename='icon.png') }}" alt="Plugin Icon" class="plugin-icon">
<span class="plugin-instance">{{ plugin_instance.name }}</span>
<button type="button" class="plugin-instance editable" title="Edit Instance Name" data-playlist="{{ playlist.name }}" data-plugin-id="{{ plugin_instance.plugin_id }}" data-instance="{{ plugin_instance.name }}" onclick="enableInlineEdit(this)">{{ plugin_instance.name }}<img src="{{ url_for('static', filename='icons/edit_pencil.png') }}" alt="edit" class="instance-edit-icon"></button>

<!-- Thumbnail preview - only show if image has been generated -->
{% if plugin_instance.latest_refresh_time %}
Expand Down Expand Up @@ -441,7 +572,7 @@ <h1 class="app-title">Playlists</h1>
</span>
{% endif %}

<a href="{{ url_for('plugin.plugin_page', plugin_id=plugin_instance.plugin_id) }}?instance={{ plugin_instance.name }}" class="edit-button">
<a href="{{ url_for('plugin.plugin_page', plugin_id=plugin_instance.plugin_id) }}?instance={{ plugin_instance.name }}" title="Edit Instance Settings" class="edit-button">
<img src="{{ url_for('static', filename='icons/edit.png') }}" alt="edit plugin" class="action-icon">
</a>
<button class="edit-button refresh-settings-btn"
Expand Down