mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 07:42:47 +01:00
New feature: Watchlist Export/Import (closes #5)
This commit is contained in:
@@ -18,7 +18,7 @@ I think it's also worth mentioning that before the 1.0 release, you can expect t
|
||||
- [Feature Requests Completed](#-feature-requests-completed)
|
||||
- [Feature Requests Planned](#-feature-requests-planned)
|
||||
- [Core Features](#-core-features)
|
||||
- [Watchlist Page](#watchlist-page)
|
||||
- [Watchlist](#watchlist)
|
||||
- [Enhanced Home Screen](#enhanced-home-screen)
|
||||
- [Enhanced Search](#enhanced-search)
|
||||
- [UI Enhancements](#-ui-enhancements)
|
||||
@@ -62,7 +62,7 @@ I think it's also worth mentioning that before the 1.0 release, you can expect t
|
||||
0a. **Install Prerequisites listed above**
|
||||
0b. **If you are using JellyfinEnhanced, you MUST disable the "Watchlist" feature from the plugin settings**
|
||||
1. **Add a new script to your JS Injector Plugin**
|
||||
2. **Copy this entire contents of kefinTweaks.js into the new script**
|
||||
2. **Copy the entire contents of kefinTweaks.js into the new script**
|
||||
|
||||
**Optionally update the "scriptRoot" in the KefinTweaks script if you wish to customize and host these scripts yourself.**
|
||||
|
||||
@@ -136,7 +136,7 @@ The features in KefinTweaks which use local data caching are listed below, along
|
||||
|
||||
### Core Features
|
||||
|
||||
#### **Watchlist Page**
|
||||
#### **Watchlist**
|
||||
- **Watchlist**:
|
||||
Tired of forgetting everything you wanted to watch? Add movies, series, seasons, and episodes to your Watchlist!
|
||||
|
||||
@@ -168,6 +168,21 @@ A summary of your watched items by the numbers. See how many movies, shows or ep
|
||||
<img src="pages/images/statistics.png" alt="statistics" style="max-width: 100%; height: auto;"/>
|
||||
</div>
|
||||
|
||||
- **Export/Import**:
|
||||
Export your Watchlist to JSON which can later be imported using the Watchlist Import feature. You can use this feature to make bulk changes to your Watchlist if you need to.
|
||||
|
||||
<table align="center" style="width: 100%;">
|
||||
<tr>
|
||||
<th style="text-align:center;">Watchlist Export</th>
|
||||
<th style="text-align:center;">Watchlist Import</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width: 50%;"><img src="pages/images/watchlist-export.png" width="1000"/></td>
|
||||
<td style="width: 50%;"><img src="pages/images/watchlist-import.png" width="1000"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
<br/>
|
||||
|
||||
- **Auto-Remove**:
|
||||
Automatically removes watched items from your watchlist when playback completes.
|
||||
|
||||
@@ -423,6 +438,7 @@ Allow users to also customize various configuration options through the UI
|
||||
<hr>
|
||||
|
||||
### Version History
|
||||
- **v0.2.4**: New Feature: Watchlist Export/Import
|
||||
- **v0.2.3**: New Feature: Collection Sorting, general fixes
|
||||
- **v0.2.2**: Add support for skin server versions, fixes for HeaderTabs
|
||||
- **v0.2.1**: Caching improvements for Watchlist and Top People, Mobile UI fixes and Flatten TV Shows improvements
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
'use strict';
|
||||
|
||||
console.log('[KefinTweaks Injector] Initializing...');
|
||||
const versionNumber = "0.2.3";
|
||||
const versionNumber = "0.2.4";
|
||||
|
||||
// Configuration: Start with defaults, then merge user config
|
||||
// This allows new scripts to work out of the box without requiring config updates
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -1866,6 +1866,58 @@ div[data-tab="watchlist"] .loading-message {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Import/Export Modal Styles */
|
||||
.import-export-modal-content {
|
||||
min-width: 500px;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.import-export-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.import-export-tab {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.import-export-tab:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.import-export-tab.active {
|
||||
color: #fff;
|
||||
border-bottom-color: #00a4dc;
|
||||
}
|
||||
|
||||
.import-export-tab-content {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* Responsive Design for Import/Export Modal */
|
||||
@media (max-width: 768px) {
|
||||
.import-export-modal-content {
|
||||
min-width: calc(100vw - 40px);
|
||||
max-width: calc(100vw - 40px);
|
||||
}
|
||||
|
||||
.import-export-tab-content {
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* List View Styling */
|
||||
[data-layout='List'] .cardScalable {
|
||||
display: none;
|
||||
@@ -1901,3 +1953,8 @@ div[data-tab="watchlist"] .loading-message {
|
||||
padding-top: .24em;
|
||||
}
|
||||
|
||||
.import-export-tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
+802
-1
@@ -145,6 +145,8 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
// Watchlist tab refresh button
|
||||
const watchlistHeaderRight = watchlistSection.querySelector('.watchlist-header-right');
|
||||
if (watchlistHeaderRight) {
|
||||
const importExportBtn = createImportExportButton();
|
||||
watchlistHeaderRight.appendChild(importExportBtn);
|
||||
const refreshBtn = createRefreshButton('watchlist', 'Watchlist');
|
||||
watchlistHeaderRight.appendChild(refreshBtn);
|
||||
}
|
||||
@@ -215,6 +217,20 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
return refreshBtn;
|
||||
}
|
||||
|
||||
// Create import/export button element
|
||||
function createImportExportButton() {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'layout-toggle-btn';
|
||||
btn.innerHTML = '<span class="material-icons import_export"></span>';
|
||||
btn.title = 'Import/Export Watchlist';
|
||||
|
||||
btn.onclick = () => {
|
||||
showImportExportModal();
|
||||
};
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
// Create sort button element
|
||||
function createSortButton() {
|
||||
const sortBtn = document.createElement('button');
|
||||
@@ -3252,6 +3268,791 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
LOG('Watchlist HTML structure rendered successfully');
|
||||
}
|
||||
|
||||
/************ Import/Export Functions ************/
|
||||
|
||||
// Show import/export modal
|
||||
function showImportExportModal() {
|
||||
const modalContent = document.createElement('div');
|
||||
modalContent.className = 'import-export-modal-content';
|
||||
modalContent.style.minWidth = '500px';
|
||||
modalContent.style.maxWidth = '800px';
|
||||
|
||||
// Create tab buttons
|
||||
const tabContainer = document.createElement('div');
|
||||
tabContainer.className = 'import-export-tabs';
|
||||
tabContainer.style.display = 'flex';
|
||||
tabContainer.style.gap = '8px';
|
||||
tabContainer.style.marginBottom = '20px';
|
||||
tabContainer.style.borderBottom = '1px solid rgba(255, 255, 255, 0.1)';
|
||||
|
||||
const exportTabBtn = document.createElement('button');
|
||||
exportTabBtn.className = 'import-export-tab active';
|
||||
exportTabBtn.textContent = 'Export';
|
||||
exportTabBtn.style.cssText = 'flex: 1; padding: 12px; background: transparent; border: none; border-bottom: 2px solid #00a4dc; color: rgba(255, 255, 255, 0.7); cursor: pointer; font-size: 14px; font-weight: 500;';
|
||||
|
||||
const importTabBtn = document.createElement('button');
|
||||
importTabBtn.className = 'import-export-tab';
|
||||
importTabBtn.textContent = 'Import';
|
||||
importTabBtn.style.cssText = 'flex: 1; padding: 12px; background: transparent; border: none; border-bottom: 2px solid transparent; color: rgba(255, 255, 255, 0.7); cursor: pointer; font-size: 14px; font-weight: 500;';
|
||||
|
||||
// Tab content container
|
||||
const tabContent = document.createElement('div');
|
||||
tabContent.className = 'import-export-tab-content';
|
||||
tabContent.style.minHeight = '400px';
|
||||
|
||||
// Active tab state
|
||||
let activeTab = 'export';
|
||||
|
||||
// Store exported data to preserve across tab switches
|
||||
let savedExportData = '';
|
||||
|
||||
// Tab switching
|
||||
const switchTab = (tab) => {
|
||||
// Save export data before clearing
|
||||
if (activeTab === 'export') {
|
||||
const exportTextarea = tabContent.querySelector('#export-textarea');
|
||||
if (exportTextarea) {
|
||||
savedExportData = exportTextarea.value;
|
||||
}
|
||||
}
|
||||
|
||||
activeTab = tab;
|
||||
exportTabBtn.classList.toggle('active', tab === 'export');
|
||||
importTabBtn.classList.toggle('active', tab === 'import');
|
||||
exportTabBtn.style.borderBottomColor = tab === 'export' ? '#00a4dc' : 'transparent';
|
||||
exportTabBtn.style.color = tab === 'export' ? '#fff' : 'rgba(255, 255, 255, 0.7)';
|
||||
importTabBtn.style.borderBottomColor = tab === 'import' ? '#00a4dc' : 'transparent';
|
||||
importTabBtn.style.color = tab === 'import' ? '#fff' : 'rgba(255, 255, 255, 0.7)';
|
||||
|
||||
tabContent.innerHTML = '';
|
||||
if (tab === 'export') {
|
||||
renderExportTab(tabContent, savedExportData, (newData) => {
|
||||
savedExportData = newData;
|
||||
});
|
||||
} else {
|
||||
renderImportTab(tabContent);
|
||||
}
|
||||
};
|
||||
|
||||
exportTabBtn.onclick = () => switchTab('export');
|
||||
importTabBtn.onclick = () => switchTab('import');
|
||||
|
||||
tabContainer.appendChild(exportTabBtn);
|
||||
tabContainer.appendChild(importTabBtn);
|
||||
modalContent.appendChild(tabContainer);
|
||||
modalContent.appendChild(tabContent);
|
||||
|
||||
// Render default tab (export)
|
||||
renderExportTab(tabContent, savedExportData, (newData) => {
|
||||
savedExportData = newData;
|
||||
});
|
||||
|
||||
// Create modal
|
||||
const modal = window.ModalSystem.create({
|
||||
id: 'import-export-modal',
|
||||
content: modalContent,
|
||||
closeOnBackdrop: true,
|
||||
closeOnEscape: true
|
||||
});
|
||||
}
|
||||
|
||||
// Render export tab
|
||||
function renderExportTab(container, savedData = '', updateSavedData = null) {
|
||||
container.innerHTML = '';
|
||||
|
||||
const description = document.createElement('p');
|
||||
description.style.cssText = 'color: rgba(255, 255, 255, 0.7); margin-bottom: 20px; font-size: 14px; line-height: 1.5;';
|
||||
description.textContent = 'Export your watchlist to a JSON file. The export includes item names and provider IDs (IMDb, TMDB, TVDB).';
|
||||
container.appendChild(description);
|
||||
|
||||
const exportBtn = document.createElement('button');
|
||||
exportBtn.className = 'emby-button emby-button-raised button-submit';
|
||||
exportBtn.textContent = 'Export Watchlist';
|
||||
exportBtn.style.cssText = 'margin-bottom: 20px;';
|
||||
exportBtn.style.alignSelf = 'flex-start';
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.id = 'export-textarea';
|
||||
textarea.readOnly = true;
|
||||
textarea.style.cssText = 'width: 100%; min-height: 300px; padding: 12px; background: rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 4px; color: #fff; font-family: monospace; font-size: 12px; resize: vertical; box-sizing: border-box;';
|
||||
textarea.placeholder = 'Exported watchlist data will appear here...';
|
||||
// Preserve saved data if switching tabs
|
||||
if (savedData) {
|
||||
textarea.value = savedData;
|
||||
}
|
||||
container.appendChild(exportBtn);
|
||||
container.appendChild(textarea);
|
||||
|
||||
const buttonContainer = document.createElement('div');
|
||||
buttonContainer.style.cssText = 'display: flex; gap: 10px; margin-top: 10px;';
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'emby-button';
|
||||
copyBtn.textContent = 'Copy to Clipboard';
|
||||
copyBtn.style.cssText = 'padding: 8px 16px; background: rgba(255, 255, 255, 0.1); color: white; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 4px; cursor: pointer; font-size: 14px;';
|
||||
// Enable copy button if there's saved content
|
||||
copyBtn.disabled = !savedData;
|
||||
|
||||
copyBtn.onclick = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(textarea.value);
|
||||
copyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => {
|
||||
copyBtn.textContent = 'Copy to Clipboard';
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
ERR('Failed to copy to clipboard:', err);
|
||||
alert('Failed to copy to clipboard. Please copy manually.');
|
||||
}
|
||||
};
|
||||
|
||||
buttonContainer.appendChild(copyBtn);
|
||||
container.appendChild(buttonContainer);
|
||||
|
||||
exportBtn.onclick = async () => {
|
||||
exportBtn.disabled = true;
|
||||
exportBtn.textContent = 'Exporting...';
|
||||
textarea.value = '';
|
||||
|
||||
try {
|
||||
const exportData = await exportWatchlistData();
|
||||
const jsonString = JSON.stringify(exportData, null, 2);
|
||||
textarea.value = jsonString;
|
||||
// Update saved data so it persists across tab switches
|
||||
if (updateSavedData) {
|
||||
updateSavedData(jsonString);
|
||||
}
|
||||
copyBtn.disabled = false;
|
||||
exportBtn.disabled = false;
|
||||
exportBtn.textContent = 'Export Watchlist';
|
||||
} catch (err) {
|
||||
ERR('Export failed:', err);
|
||||
textarea.value = `Error exporting watchlist: ${err.message}`;
|
||||
// Clear saved data on error
|
||||
if (updateSavedData) {
|
||||
updateSavedData('');
|
||||
}
|
||||
exportBtn.disabled = false;
|
||||
exportBtn.textContent = 'Export Watchlist';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Render import tab
|
||||
function renderImportTab(container) {
|
||||
container.innerHTML = '';
|
||||
|
||||
const description = document.createElement('p');
|
||||
description.style.cssText = 'color: rgba(255, 255, 255, 0.7); margin-bottom: 20px; font-size: 14px; line-height: 1.5;';
|
||||
description.textContent = 'Paste your watchlist JSON data below. Each item must have at least one provider ID (IMDb, TMDB, or TVDB).';
|
||||
container.appendChild(description);
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.id = 'import-textarea';
|
||||
textarea.style.cssText = 'width: 100%; min-height: 200px; padding: 12px; background: rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 4px; color: #fff; font-family: monospace; font-size: 12px; resize: vertical; box-sizing: border-box; margin-bottom: 10px;';
|
||||
textarea.placeholder = 'Paste your watchlist JSON data here...';
|
||||
container.appendChild(textarea);
|
||||
|
||||
const validationMsg = document.createElement('div');
|
||||
validationMsg.id = 'import-validation';
|
||||
validationMsg.style.cssText = 'margin-bottom: 10px; padding: 10px; border-radius: 4px; font-size: 13px; display: none;';
|
||||
container.appendChild(validationMsg);
|
||||
|
||||
const previewContainer = document.createElement('div');
|
||||
previewContainer.id = 'import-preview';
|
||||
previewContainer.style.cssText = 'margin-bottom: 10px; max-height: 200px; overflow-y: auto; display: none;';
|
||||
container.appendChild(previewContainer);
|
||||
|
||||
const progressContainer = document.createElement('div');
|
||||
progressContainer.id = 'import-progress';
|
||||
progressContainer.style.cssText = 'margin-bottom: 10px; display: none;';
|
||||
container.appendChild(progressContainer);
|
||||
|
||||
const summaryContainer = document.createElement('div');
|
||||
summaryContainer.id = 'import-summary';
|
||||
summaryContainer.style.cssText = 'margin-bottom: 10px; padding: 10px; border-radius: 4px; display: none;';
|
||||
container.appendChild(summaryContainer);
|
||||
|
||||
const buttonContainer = document.createElement('div');
|
||||
buttonContainer.style.cssText = 'display: flex; gap: 10px; margin-top: 10px;';
|
||||
|
||||
const validateBtn = document.createElement('button');
|
||||
validateBtn.className = 'emby-button';
|
||||
validateBtn.textContent = 'Validate & Preview';
|
||||
validateBtn.style.cssText = 'padding: 8px 16px; background: rgba(255, 255, 255, 0.1); color: white; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 4px; cursor: pointer; font-size: 14px;';
|
||||
|
||||
const importBtn = document.createElement('button');
|
||||
importBtn.className = 'emby-button emby-button-raised button-submit';
|
||||
importBtn.textContent = 'Import Watchlist';
|
||||
importBtn.disabled = true;
|
||||
importBtn.style.display = 'none'; // Hide until validated
|
||||
|
||||
const clearBtn = document.createElement('button');
|
||||
clearBtn.className = 'emby-button';
|
||||
clearBtn.textContent = 'Clear Watchlist';
|
||||
clearBtn.style.cssText = 'padding: 8px 16px; background: rgba(255, 0, 0, 0.2); color: #ff6b6b; border: 1px solid rgba(255, 0, 0, 0.3); border-radius: 4px; cursor: pointer; font-size: 14px; margin-left: auto;';
|
||||
|
||||
buttonContainer.appendChild(validateBtn);
|
||||
buttonContainer.appendChild(importBtn);
|
||||
buttonContainer.appendChild(clearBtn);
|
||||
container.appendChild(buttonContainer);
|
||||
|
||||
let validatedData = null;
|
||||
|
||||
validateBtn.onclick = () => {
|
||||
const jsonText = textarea.value.trim();
|
||||
if (!jsonText) {
|
||||
showValidationMessage(validationMsg, 'Please paste JSON data first.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide progress container when validating
|
||||
progressContainer.style.display = 'none';
|
||||
summaryContainer.style.display = 'none';
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonText);
|
||||
const validation = validateImportData(data);
|
||||
|
||||
if (validation.valid) {
|
||||
validatedData = data;
|
||||
showValidationMessage(validationMsg, validation.message, 'success');
|
||||
showPreview(previewContainer, data);
|
||||
importBtn.disabled = false;
|
||||
importBtn.style.display = ''; // Show button when validated
|
||||
} else {
|
||||
validatedData = null;
|
||||
showValidationMessage(validationMsg, validation.message, 'error');
|
||||
previewContainer.style.display = 'none';
|
||||
importBtn.disabled = true;
|
||||
importBtn.style.display = 'none'; // Hide button on validation failure
|
||||
}
|
||||
} catch (err) {
|
||||
validatedData = null;
|
||||
showValidationMessage(validationMsg, `Invalid JSON: ${err.message}`, 'error');
|
||||
previewContainer.style.display = 'none';
|
||||
importBtn.disabled = true;
|
||||
importBtn.style.display = 'none'; // Hide button on parse error
|
||||
}
|
||||
};
|
||||
|
||||
importBtn.onclick = async () => {
|
||||
if (!validatedData) {
|
||||
showValidationMessage(validationMsg, 'Please validate the data first.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
importBtn.disabled = true;
|
||||
validateBtn.disabled = true;
|
||||
clearBtn.disabled = true;
|
||||
summaryContainer.style.display = 'none';
|
||||
|
||||
// Show progress container immediately at 0/X items
|
||||
progressContainer.style.display = 'block';
|
||||
showImportProgress(progressContainer, 0, validatedData.length, 'Starting import...');
|
||||
|
||||
try {
|
||||
const results = await importWatchlistData(validatedData, progressContainer);
|
||||
showImportSummary(summaryContainer, results);
|
||||
summaryContainer.style.display = 'block';
|
||||
|
||||
// Refresh watchlist
|
||||
await initWatchlistTab();
|
||||
renderWatchlistContent();
|
||||
} catch (err) {
|
||||
ERR('Import failed:', err);
|
||||
showValidationMessage(validationMsg, `Import failed: ${err.message}`, 'error');
|
||||
} finally {
|
||||
importBtn.disabled = false;
|
||||
validateBtn.disabled = false;
|
||||
clearBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
clearBtn.onclick = async () => {
|
||||
if (confirm('Are you sure you want to clear your entire watchlist? This action cannot be undone.')) {
|
||||
if (confirm('This will remove ALL items from your watchlist. Are you absolutely sure?')) {
|
||||
clearBtn.disabled = true;
|
||||
clearBtn.textContent = 'Clearing...';
|
||||
try {
|
||||
await clearWatchlist();
|
||||
// Close modal after clearing
|
||||
window.ModalSystem.close('import-export-modal');
|
||||
} catch (err) {
|
||||
ERR('Failed to clear watchlist:', err);
|
||||
alert('Failed to clear watchlist. Please try again.');
|
||||
} finally {
|
||||
clearBtn.disabled = false;
|
||||
clearBtn.textContent = 'Clear Watchlist';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Show validation message
|
||||
function showValidationMessage(container, message, type) {
|
||||
container.style.display = 'block';
|
||||
container.textContent = message;
|
||||
container.style.background = type === 'success' ? 'rgba(76, 175, 80, 0.2)' : 'rgba(255, 0, 0, 0.2)';
|
||||
container.style.border = type === 'success' ? '1px solid rgba(76, 175, 80, 0.4)' : '1px solid rgba(255, 0, 0, 0.4)';
|
||||
container.style.color = type === 'success' ? '#4caf50' : '#ff6b6b';
|
||||
}
|
||||
|
||||
// Show preview
|
||||
function showPreview(container, data) {
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'block';
|
||||
|
||||
const previewTitle = document.createElement('div');
|
||||
previewTitle.style.cssText = 'font-weight: 600; margin-bottom: 8px; color: #fff;';
|
||||
previewTitle.textContent = `Preview: ${data.length} item(s) to import`;
|
||||
container.appendChild(previewTitle);
|
||||
|
||||
const previewList = document.createElement('div');
|
||||
previewList.style.cssText = 'max-height: 150px; overflow-y: auto;';
|
||||
|
||||
data.slice(0, 10).forEach((item, index) => {
|
||||
const itemDiv = document.createElement('div');
|
||||
itemDiv.style.cssText = 'padding: 6px; margin-bottom: 4px; background: rgba(255, 255, 255, 0.05); border-radius: 4px; font-size: 12px; color: rgba(255, 255, 255, 0.8);';
|
||||
const name = item.Name || item.name || 'Unknown';
|
||||
const providers = [];
|
||||
if (item.Imdb || item.imdb) providers.push('IMDb');
|
||||
if (item.Tmdb || item.tmdb) providers.push('TMDB');
|
||||
if (item.Tvdb || item.tvdb) providers.push('TVDB');
|
||||
itemDiv.textContent = `${index + 1}. ${name} (${providers.join(', ')})`;
|
||||
previewList.appendChild(itemDiv);
|
||||
});
|
||||
|
||||
if (data.length > 10) {
|
||||
const moreDiv = document.createElement('div');
|
||||
moreDiv.style.cssText = 'padding: 6px; font-size: 12px; color: rgba(255, 255, 255, 0.6); font-style: italic;';
|
||||
moreDiv.textContent = `... and ${data.length - 10} more item(s)`;
|
||||
previewList.appendChild(moreDiv);
|
||||
}
|
||||
|
||||
container.appendChild(previewList);
|
||||
}
|
||||
|
||||
// Show import progress
|
||||
function showImportProgress(container, current, total, itemName) {
|
||||
container.innerHTML = `
|
||||
<div style="color: rgba(255, 255, 255, 0.8); font-size: 13px; margin-bottom: 8px;">
|
||||
Importing: ${current} / ${total}
|
||||
</div>
|
||||
<div style="width: 100%; height: 8px; background: rgba(255, 255, 255, 0.1); border-radius: 4px; overflow: hidden;">
|
||||
<div style="width: ${(current / total) * 100}%; height: 100%; background: #00a4dc; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
<div style="color: rgba(255, 255, 255, 0.6); font-size: 12px; margin-top: 4px;">
|
||||
${itemName || ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Show import summary
|
||||
function showImportSummary(container, results) {
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'block';
|
||||
container.style.background = 'rgba(255, 255, 255, 0.05)';
|
||||
container.style.border = '1px solid rgba(255, 255, 255, 0.1)';
|
||||
container.style.padding = '12px';
|
||||
container.style.borderRadius = '4px';
|
||||
|
||||
const summaryTitle = document.createElement('div');
|
||||
summaryTitle.style.cssText = 'font-weight: 600; margin-bottom: 8px; color: #fff; font-size: 14px;';
|
||||
summaryTitle.textContent = 'Import Summary';
|
||||
container.appendChild(summaryTitle);
|
||||
|
||||
const summaryList = document.createElement('div');
|
||||
summaryList.style.cssText = 'display: flex; flex-direction: column; gap: 4px; font-size: 13px;';
|
||||
|
||||
const successDiv = document.createElement('div');
|
||||
successDiv.style.color = '#4caf50';
|
||||
successDiv.textContent = `✓ Successfully imported: ${results.imported} item(s)`;
|
||||
summaryList.appendChild(successDiv);
|
||||
|
||||
if (results.skipped > 0) {
|
||||
const skippedDiv = document.createElement('div');
|
||||
skippedDiv.style.color = 'rgba(255, 255, 255, 0.7)';
|
||||
skippedDiv.textContent = `⊘ Already in watchlist: ${results.skipped} item(s)`;
|
||||
summaryList.appendChild(skippedDiv);
|
||||
}
|
||||
|
||||
if (results.notFound > 0) {
|
||||
const notFoundDiv = document.createElement('div');
|
||||
notFoundDiv.style.color = '#ff6b6b';
|
||||
notFoundDiv.textContent = `✗ Not found in library: ${results.notFound} item(s)`;
|
||||
summaryList.appendChild(notFoundDiv);
|
||||
}
|
||||
|
||||
if (results.errors > 0) {
|
||||
const errorsDiv = document.createElement('div');
|
||||
errorsDiv.style.color = '#ff6b6b';
|
||||
errorsDiv.textContent = `✗ Errors: ${results.errors} item(s)`;
|
||||
summaryList.appendChild(errorsDiv);
|
||||
}
|
||||
|
||||
container.appendChild(summaryList);
|
||||
}
|
||||
|
||||
// Export watchlist data from server
|
||||
async function exportWatchlistData() {
|
||||
const apiClient = window.ApiClient;
|
||||
const userId = apiClient.getCurrentUserId();
|
||||
const serverUrl = apiClient.serverAddress();
|
||||
const token = apiClient.accessToken();
|
||||
|
||||
const exportData = [];
|
||||
|
||||
// Fetch all watchlist items from server (not cache)
|
||||
const types = [
|
||||
{ type: 'Movie', section: 'movies' },
|
||||
{ type: 'Series', section: 'series' },
|
||||
{ type: 'Season', section: 'seasons' },
|
||||
{ type: 'Episode', section: 'episodes' }
|
||||
];
|
||||
|
||||
for (const { type, section } of types) {
|
||||
try {
|
||||
// Fetch from server with Fields to get SeriesName for episodes/seasons
|
||||
const url = `${serverUrl}/Items?Filters=Likes&IncludeItemTypes=${type}&UserId=${userId}&Recursive=true&Fields=ProviderIds,SeriesName,ParentId&ImageTypeLimit=1&EnableImageTypes=Primary,Backdrop,Thumb`;
|
||||
const res = await fetch(url, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
|
||||
const data = await res.json();
|
||||
const items = data.Items || [];
|
||||
|
||||
for (const item of items) {
|
||||
const exportItem = {
|
||||
status: true,
|
||||
Name: item.Name,
|
||||
Type: type // Add Type field for efficient import
|
||||
};
|
||||
|
||||
// Add provider IDs (only if they exist)
|
||||
if (item.ProviderIds) {
|
||||
if (item.ProviderIds.Imdb) exportItem.Imdb = item.ProviderIds.Imdb;
|
||||
if (item.ProviderIds.Tmdb) exportItem.Tmdb = item.ProviderIds.Tmdb;
|
||||
if (item.ProviderIds.Tvdb) exportItem.Tvdb = item.ProviderIds.Tvdb;
|
||||
}
|
||||
|
||||
// For episodes, add SeriesName and SeasonName
|
||||
if (type === 'Episode') {
|
||||
if (item.SeriesName) {
|
||||
exportItem.SeriesName = item.SeriesName;
|
||||
}
|
||||
// Get SeasonName from parent season
|
||||
if (item.ParentId) {
|
||||
try {
|
||||
const seasonUrl = `${serverUrl}/Items/${item.ParentId}?UserId=${userId}&Fields=Name`;
|
||||
const seasonRes = await fetch(seasonUrl, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
|
||||
const seasonData = await seasonRes.json();
|
||||
if (seasonData.Name) {
|
||||
exportItem.SeasonName = seasonData.Name;
|
||||
}
|
||||
} catch (err) {
|
||||
WARN('Failed to fetch season name for episode:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For seasons, add SeriesName
|
||||
if (type === 'Season') {
|
||||
if (item.SeriesName) {
|
||||
exportItem.SeriesName = item.SeriesName;
|
||||
}
|
||||
}
|
||||
|
||||
// Only add if at least one provider ID exists
|
||||
if (exportItem.Imdb || exportItem.Tmdb || exportItem.Tvdb) {
|
||||
exportData.push(exportItem);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
ERR(`Failed to fetch ${type} items:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return exportData;
|
||||
}
|
||||
|
||||
// Validate import data
|
||||
function validateImportData(data) {
|
||||
if (!Array.isArray(data)) {
|
||||
return { valid: false, message: 'Data must be an array of items.' };
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return { valid: false, message: 'No items to import.' };
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
const providerIdMap = new Map(); // Track provider IDs for duplicate detection
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const item = data[i];
|
||||
const itemNum = i + 1;
|
||||
|
||||
// Check if item is an object
|
||||
if (typeof item !== 'object' || item === null) {
|
||||
errors.push(`Item ${itemNum}: Must be an object.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check status field
|
||||
if (item.status !== undefined && typeof item.status !== 'boolean') {
|
||||
errors.push(`Item ${itemNum}: 'status' must be a boolean.`);
|
||||
}
|
||||
|
||||
// Check Type field (required)
|
||||
const itemType = item.Type || item.type;
|
||||
if (!itemType) {
|
||||
errors.push(`Item ${itemNum}: 'Type' field is required (Movie, Series, Season, or Episode).`);
|
||||
continue;
|
||||
}
|
||||
if (!['Movie', 'Series', 'Season', 'Episode'].includes(itemType)) {
|
||||
errors.push(`Item ${itemNum}: 'Type' must be one of: Movie, Series, Season, Episode.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for at least one provider ID
|
||||
const imdb = item.Imdb || item.imdb;
|
||||
const tmdb = item.Tmdb || item.tmdb;
|
||||
const tvdb = item.Tvdb || item.tvdb;
|
||||
|
||||
if (!imdb && !tmdb && !tvdb) {
|
||||
errors.push(`Item ${itemNum}: Must have at least one provider ID (Imdb, Tmdb, or Tvdb).`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for duplicate provider IDs (include Type in key to allow same ID for different types)
|
||||
const providerKeys = [];
|
||||
if (imdb) providerKeys.push(`Imdb:${imdb}:${itemType}`);
|
||||
if (tmdb) providerKeys.push(`Tmdb:${tmdb}:${itemType}`);
|
||||
if (tvdb) providerKeys.push(`Tvdb:${tvdb}:${itemType}`);
|
||||
|
||||
for (const key of providerKeys) {
|
||||
if (providerIdMap.has(key)) {
|
||||
const existingItem = providerIdMap.get(key);
|
||||
errors.push(`Item ${itemNum}: Duplicate provider ID '${key}' found (also in item ${existingItem}).`);
|
||||
} else {
|
||||
providerIdMap.set(key, itemNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { valid: false, message: `Validation failed:\n${errors.slice(0, 10).join('\n')}${errors.length > 10 ? `\n... and ${errors.length - 10} more error(s).` : ''}` };
|
||||
}
|
||||
|
||||
return { valid: true, message: `Validation successful: ${data.length} item(s) ready to import.` };
|
||||
}
|
||||
|
||||
// Import watchlist data
|
||||
async function importWatchlistData(data, progressContainer) {
|
||||
const apiClient = window.ApiClient;
|
||||
const userId = apiClient.getCurrentUserId();
|
||||
const serverUrl = apiClient.serverAddress();
|
||||
const token = apiClient.accessToken();
|
||||
|
||||
const results = {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
notFound: 0,
|
||||
errors: 0
|
||||
};
|
||||
|
||||
// Analyze import data to determine which types are used
|
||||
const usedTypes = new Set();
|
||||
for (const item of data) {
|
||||
const itemType = item.Type || item.type;
|
||||
if (itemType) usedTypes.add(itemType);
|
||||
}
|
||||
|
||||
// Build query parameters
|
||||
const includeTypes = Array.from(usedTypes).join(',');
|
||||
const queryParams = new URLSearchParams({
|
||||
UserId: userId,
|
||||
Recursive: 'true',
|
||||
IncludeItemTypes: includeTypes,
|
||||
Fields: 'ProviderIds,Type'
|
||||
});
|
||||
|
||||
// Fetch all matching items in bulk
|
||||
let allLibraryItems = [];
|
||||
try {
|
||||
const url = `${serverUrl}/Items?${queryParams.toString()}`;
|
||||
const res = await fetch(url, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
|
||||
const libraryData = await res.json();
|
||||
allLibraryItems = libraryData.Items || [];
|
||||
LOG(`Fetched ${allLibraryItems.length} items from library matching import criteria`);
|
||||
} catch (err) {
|
||||
ERR('Failed to fetch library items:', err);
|
||||
throw new Error('Failed to fetch library items. Please try again.');
|
||||
}
|
||||
|
||||
// Create a lookup map: providerId -> item
|
||||
const libraryMap = new Map();
|
||||
for (const item of allLibraryItems) {
|
||||
if (item.ProviderIds) {
|
||||
if (item.ProviderIds.Imdb) {
|
||||
const key = `Imdb:${item.ProviderIds.Imdb}:${item.Type}`;
|
||||
libraryMap.set(key, item);
|
||||
}
|
||||
if (item.ProviderIds.Tmdb) {
|
||||
const key = `Tmdb:${item.ProviderIds.Tmdb}:${item.Type}`;
|
||||
libraryMap.set(key, item);
|
||||
}
|
||||
if (item.ProviderIds.Tvdb) {
|
||||
const key = `Tvdb:${item.ProviderIds.Tvdb}:${item.Type}`;
|
||||
libraryMap.set(key, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get all current watchlist items to check for duplicates
|
||||
const currentWatchlist = new Set();
|
||||
const watchlistTypes = ['Movie', 'Series', 'Season', 'Episode'];
|
||||
for (const type of watchlistTypes) {
|
||||
try {
|
||||
const url = `${serverUrl}/Items?Filters=Likes&IncludeItemTypes=${type}&UserId=${userId}&Recursive=true&Fields=ProviderIds`;
|
||||
const res = await fetch(url, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
|
||||
const watchlistData = await res.json();
|
||||
const items = watchlistData.Items || [];
|
||||
for (const item of items) {
|
||||
if (item.ProviderIds) {
|
||||
if (item.ProviderIds.Imdb) currentWatchlist.add(`Imdb:${item.ProviderIds.Imdb}:${type}`);
|
||||
if (item.ProviderIds.Tmdb) currentWatchlist.add(`Tmdb:${item.ProviderIds.Tmdb}:${type}`);
|
||||
if (item.ProviderIds.Tvdb) currentWatchlist.add(`Tvdb:${item.ProviderIds.Tvdb}:${type}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
WARN(`Failed to fetch current watchlist for ${type}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Process each import item
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const importItem = data[i];
|
||||
const itemName = importItem.Name || importItem.name || `Item ${i + 1}`;
|
||||
|
||||
// Update progress before processing item
|
||||
showImportProgress(progressContainer, i + 1, data.length, itemName);
|
||||
|
||||
// Allow UI to update before processing
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
try {
|
||||
// Get provider IDs and type
|
||||
const imdb = importItem.Imdb || importItem.imdb;
|
||||
const tmdb = importItem.Tmdb || importItem.tmdb;
|
||||
const tvdb = importItem.Tvdb || importItem.tvdb;
|
||||
const itemType = importItem.Type || importItem.type;
|
||||
|
||||
// Find matching item in library using provider IDs and type
|
||||
let foundItem = null;
|
||||
const searchKeys = [];
|
||||
if (imdb) searchKeys.push(`Imdb:${imdb}:${itemType}`);
|
||||
if (tmdb) searchKeys.push(`Tmdb:${tmdb}:${itemType}`);
|
||||
if (tvdb) searchKeys.push(`Tvdb:${tvdb}:${itemType}`);
|
||||
|
||||
for (const key of searchKeys) {
|
||||
if (libraryMap.has(key)) {
|
||||
foundItem = libraryMap.get(key);
|
||||
break; // Use first match
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundItem) {
|
||||
results.notFound++;
|
||||
// Update progress after processing
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already in watchlist
|
||||
let alreadyInWatchlist = false;
|
||||
for (const key of searchKeys) {
|
||||
if (currentWatchlist.has(key)) {
|
||||
alreadyInWatchlist = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (alreadyInWatchlist) {
|
||||
results.skipped++;
|
||||
// Update progress after processing
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add to watchlist
|
||||
await apiClient.updateUserItemRating(userId, foundItem.Id, 'true');
|
||||
results.imported++;
|
||||
|
||||
// Update current watchlist set
|
||||
if (foundItem.ProviderIds) {
|
||||
if (foundItem.ProviderIds.Imdb) currentWatchlist.add(`Imdb:${foundItem.ProviderIds.Imdb}:${foundItem.Type}`);
|
||||
if (foundItem.ProviderIds.Tmdb) currentWatchlist.add(`Tmdb:${foundItem.ProviderIds.Tmdb}:${foundItem.Type}`);
|
||||
if (foundItem.ProviderIds.Tvdb) currentWatchlist.add(`Tvdb:${foundItem.ProviderIds.Tvdb}:${foundItem.Type}`);
|
||||
}
|
||||
|
||||
// Update progress after processing
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
} catch (err) {
|
||||
ERR(`Error importing item ${i + 1}:`, err);
|
||||
results.errors++;
|
||||
// Update progress after error
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Clear entire watchlist
|
||||
async function clearWatchlist() {
|
||||
const apiClient = window.ApiClient;
|
||||
const userId = apiClient.getCurrentUserId();
|
||||
const serverUrl = apiClient.serverAddress();
|
||||
const token = apiClient.accessToken();
|
||||
|
||||
const types = ['Movie', 'Series', 'Season', 'Episode'];
|
||||
let cleared = 0;
|
||||
|
||||
for (const type of types) {
|
||||
try {
|
||||
const url = `${serverUrl}/Items?Filters=Likes&IncludeItemTypes=${type}&UserId=${userId}&Recursive=true&Fields=Id`;
|
||||
const res = await fetch(url, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } });
|
||||
const data = await res.json();
|
||||
const items = data.Items || [];
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
await apiClient.updateUserItemRating(userId, item.Id, 'false');
|
||||
cleared++;
|
||||
} catch (err) {
|
||||
ERR(`Failed to remove item ${item.Id}:`, err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
ERR(`Failed to fetch ${type} items for clearing:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
const sections = ['movies', 'series', 'seasons', 'episodes'];
|
||||
sections.forEach(section => {
|
||||
localStorageCache.clear(`watchlist_${section}`);
|
||||
watchlistCache[section].data = [];
|
||||
});
|
||||
|
||||
// Refresh watchlist
|
||||
await initWatchlistTab();
|
||||
renderWatchlistContent();
|
||||
|
||||
alert(`Watchlist cleared: ${cleared} item(s) removed.`);
|
||||
}
|
||||
|
||||
// Update watchlist cache when an item is toggled
|
||||
async function updateWatchlistCacheOnToggle(itemId, itemType, isAdded) {
|
||||
try {
|
||||
@@ -3386,7 +4187,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
}
|
||||
}
|
||||
|
||||
setupWebSocketMonitoring();
|
||||
//setupWebSocketMonitoring();
|
||||
|
||||
// Try to set up immediately
|
||||
/* if (!setupWebSocketMonitoring()) {
|
||||
|
||||
Reference in New Issue
Block a user