New feature: Watchlist Export/Import (closes #5)

This commit is contained in:
HighImKevin
2025-11-13 21:20:32 -05:00
parent 2df45d5b3e
commit 732591cdbc
6 changed files with 879 additions and 5 deletions
+19 -3
View File
@@ -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
View File
@@ -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

+57
View File
@@ -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
View File
@@ -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()) {