feat(jellyfin): added jellyfin plugin
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
using System.Collections.Generic;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// MovieNight plugin settings persisted by Jellyfin.
|
||||
/// </summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether integration calls are enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MovieNight backend base URL.
|
||||
/// </summary>
|
||||
public string BackendBaseUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the backend plugin token.
|
||||
/// </summary>
|
||||
public string ApiToken { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the periodic sync interval in minutes.
|
||||
/// </summary>
|
||||
public int SyncIntervalMinutes { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether playback stop events are pushed to MovieNight.
|
||||
/// </summary>
|
||||
public bool EnablePlaybackEvents { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether periodic backend sync is enabled.
|
||||
/// </summary>
|
||||
public bool EnablePeriodicSync { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets enabled Jellyfin library ids. Empty means all libraries.
|
||||
/// </summary>
|
||||
public List<string> EnabledLibraryIds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path where .strm files will be created.
|
||||
/// </summary>
|
||||
public string StrmOutputPath { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
const movieNightConfigPage = {
|
||||
pluginId: "42c72919-d6ff-4f62-bb8c-0fac39efafdb",
|
||||
|
||||
loadConfiguration(view) {
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
return ApiClient.getPluginConfiguration(this.pluginId)
|
||||
.then((config) => {
|
||||
view.querySelector("#BackendBaseUrl").value =
|
||||
config.BackendBaseUrl || "";
|
||||
view.querySelector("#ApiToken").value = config.ApiToken || "";
|
||||
view.querySelector("#SyncIntervalMinutes").value =
|
||||
config.SyncIntervalMinutes || 30;
|
||||
view.querySelector("#StrmOutputPath").value =
|
||||
config.StrmOutputPath || "";
|
||||
view.querySelector("#Enabled").checked = config.Enabled || false;
|
||||
view.querySelector("#EnablePeriodicSync").checked =
|
||||
config.EnablePeriodicSync !== false;
|
||||
view.querySelector("#EnablePlaybackEvents").checked =
|
||||
config.EnablePlaybackEvents !== false;
|
||||
|
||||
const uiScriptUrl = ApiClient.getUrl("web/ConfigurationPage", {
|
||||
name: "MovieNight.ui.js",
|
||||
});
|
||||
view.querySelector("#UIScriptUrl").innerText = uiScriptUrl;
|
||||
})
|
||||
.finally(() => {
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
},
|
||||
|
||||
saveConfiguration(view) {
|
||||
const form = view.querySelector("#MovieNightConfigForm");
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
return ApiClient.getPluginConfiguration(this.pluginId)
|
||||
.then((config) => {
|
||||
config.BackendBaseUrl = form.querySelector("#BackendBaseUrl").value;
|
||||
config.ApiToken = form.querySelector("#ApiToken").value;
|
||||
config.SyncIntervalMinutes = parseInt(
|
||||
form.querySelector("#SyncIntervalMinutes").value || "30",
|
||||
10,
|
||||
);
|
||||
config.StrmOutputPath = form.querySelector("#StrmOutputPath").value;
|
||||
config.Enabled = form.querySelector("#Enabled").checked;
|
||||
config.EnablePeriodicSync =
|
||||
form.querySelector("#EnablePeriodicSync").checked;
|
||||
config.EnablePlaybackEvents =
|
||||
form.querySelector("#EnablePlaybackEvents").checked;
|
||||
|
||||
return ApiClient.updatePluginConfiguration(this.pluginId, config);
|
||||
})
|
||||
.then((result) => {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
})
|
||||
.finally(() => {
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
},
|
||||
|
||||
testConnection() {
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
return ApiClient.ajax({
|
||||
type: "POST",
|
||||
url: ApiClient.getUrl("MovieNight/TestConnection"),
|
||||
})
|
||||
.then((result) => {
|
||||
Dashboard.alert((result && result.message) || "OK");
|
||||
})
|
||||
.catch(() => {
|
||||
Dashboard.alert("MovieNight connection test failed");
|
||||
})
|
||||
.finally(() => {
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default function (view) {
|
||||
movieNightConfigPage.loadConfiguration(view);
|
||||
|
||||
view
|
||||
.querySelector("#MovieNightConfigForm")
|
||||
.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
movieNightConfigPage.saveConfiguration(view);
|
||||
});
|
||||
|
||||
view.querySelector("#TestConnection").addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
movieNightConfigPage.testConnection();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>MovieNight</title>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="MovieNightConfigPage"
|
||||
data-role="page"
|
||||
class="page type-interior pluginConfigurationPage"
|
||||
data-controller="__plugin/MovieNight.js">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<form id="MovieNightConfigForm">
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="BackendBaseUrl">Backend URL</label>
|
||||
<input is="emby-input" id="BackendBaseUrl" name="BackendBaseUrl" type="url" placeholder="http://localhost:8080" />
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="ApiToken">Plugin token</label>
|
||||
<input is="emby-input" id="ApiToken" name="ApiToken" type="password" autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="SyncIntervalMinutes">Sync interval minutes</label>
|
||||
<input is="emby-input" id="SyncIntervalMinutes" name="SyncIntervalMinutes" type="number" min="1" max="1440" />
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="StrmOutputPath">STRM output path</label>
|
||||
<input is="emby-input" id="StrmOutputPath" name="StrmOutputPath" type="text" placeholder="/data/movies/movienight" />
|
||||
<div class="fieldDescription">Directory where .strm files will be created for new films.</div>
|
||||
</div>
|
||||
|
||||
<label class="checkboxContainer">
|
||||
<input id="Enabled" name="Enabled" type="checkbox" is="emby-checkbox" />
|
||||
<span>Enable MovieNight integration</span>
|
||||
</label>
|
||||
|
||||
<label class="checkboxContainer">
|
||||
<input id="EnablePeriodicSync" name="EnablePeriodicSync" type="checkbox" is="emby-checkbox" />
|
||||
<span>Enable periodic backend sync</span>
|
||||
</label>
|
||||
|
||||
<label class="checkboxContainer">
|
||||
<input id="EnablePlaybackEvents" name="EnablePlaybackEvents" type="checkbox" is="emby-checkbox" />
|
||||
<span>Send playback stop events</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="button" id="TestConnection" class="raised block">
|
||||
<span>Test connection</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 2em; padding: 1em; background: #333; border-radius: 4px;">
|
||||
<h3>UI Integration</h3>
|
||||
<p>To enable the "Recommend Film" button and Rating UI in the main Jellyfin interface, you must add the following script URL to your Jellyfin <strong>Custom JavaScript</strong> setting (Dashboard > General):</p>
|
||||
<code id="UIScriptUrl" style="display: block; padding: 0.5em; background: #000; word-break: break-all;"></code>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,450 @@
|
||||
(function () {
|
||||
const PLUGIN_ID = "42c72919-d6ff-4f62-bb8c-0fac39efafdb";
|
||||
|
||||
function getAlert() {
|
||||
if (typeof Dashboard !== 'undefined' && Dashboard.alert) {
|
||||
return (options) => Dashboard.alert(options);
|
||||
}
|
||||
return (options) => {
|
||||
const msg = typeof options === 'string' ? options : (options.text || options.title);
|
||||
alert(msg);
|
||||
};
|
||||
}
|
||||
|
||||
const showMsg = getAlert();
|
||||
|
||||
function createTextButton(text, className, onClick) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.is = 'emby-button';
|
||||
btn.className = `emby-button raised ${className}`;
|
||||
btn.style.margin = '0.5em';
|
||||
btn.style.padding = '0.4em 1em';
|
||||
btn.innerHTML = `<span>${text}</span>`;
|
||||
btn.onclick = onClick;
|
||||
return btn;
|
||||
}
|
||||
|
||||
function createIconButton(icon, title, className, onClick) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.is = 'emby-button';
|
||||
btn.className = `button-flat detailButton emby-button ${className}`;
|
||||
btn.title = title;
|
||||
btn.innerHTML = `
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon ${icon}" aria-hidden="true"></span>
|
||||
</div>
|
||||
`;
|
||||
btn.onclick = onClick;
|
||||
return btn;
|
||||
}
|
||||
|
||||
async function injectUI() {
|
||||
// Check for onboarding
|
||||
await checkOnboarding();
|
||||
|
||||
// 1. Item Detail Page
|
||||
const detailButtons = document.querySelector('.mainDetailButtons');
|
||||
if (detailButtons) {
|
||||
const itemId = getItemIdFromUrl();
|
||||
if (itemId) {
|
||||
// MovieNight Rating
|
||||
if (!document.querySelector('.btnMovieNightRate')) {
|
||||
const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => {
|
||||
e.preventDefault(); e.stopPropagation(); showRatingDialog(itemId);
|
||||
});
|
||||
insertInDetailRow(detailButtons, rateBtn);
|
||||
}
|
||||
// Mark Viewed in MovieNight
|
||||
if (!document.querySelector('.btnMovieNightMarkViewed')) {
|
||||
const viewedBtn = createIconButton('visibility', 'Mark Viewed in MovieNight', 'btnMovieNightMarkViewed', (e) => {
|
||||
e.preventDefault(); e.stopPropagation(); submitViewed(itemId);
|
||||
});
|
||||
insertInDetailRow(detailButtons, viewedBtn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Library Pages - Add text buttons to toolbar
|
||||
const toolBar = document.querySelector('.libraryPage:not(.itemDetailPage) .flex.align-items-center.justify-content-center.focuscontainer-x');
|
||||
if (toolBar && !document.querySelector('.btnMovieNightRecommend')) {
|
||||
toolBar.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', (e) => {
|
||||
e.preventDefault(); showRecommendation();
|
||||
}));
|
||||
toolBar.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', (e) => {
|
||||
e.preventDefault(); showAddMovieDialog();
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. Home Page - Prepend a MovieNight section
|
||||
const homeSections = document.querySelector('.sections.homeSectionsContainer');
|
||||
if (homeSections && !document.querySelector('.movieNightHomeButtons')) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'verticalSection movieNightHomeButtons';
|
||||
section.style.padding = '0 var(--sidePadding)';
|
||||
section.innerHTML = `
|
||||
<div class="sectionTitleContainer" style="display:flex; align-items:center; justify-content:space-between;">
|
||||
<h2 class="sectionTitle">MovieNight</h2>
|
||||
<span class="movieNightSyncStatus" style="font-size:0.8em; opacity:0.7;"></span>
|
||||
</div>
|
||||
<div class="movieNightBtnContainer" style="display:flex; flex-wrap:wrap; margin-top:0.5em;"></div>
|
||||
`;
|
||||
const btnContainer = section.querySelector('.movieNightBtnContainer');
|
||||
btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation));
|
||||
btnContainer.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', showAddMovieDialog));
|
||||
btnContainer.appendChild(createTextButton('Sync Library', 'btnMovieNightSync', triggerSync));
|
||||
|
||||
homeSections.insertBefore(section, homeSections.firstChild);
|
||||
updateSyncStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function insertInDetailRow(container, btn) {
|
||||
const moreBtn = container.querySelector('.btnMoreCommands');
|
||||
if (moreBtn) container.insertBefore(btn, moreBtn);
|
||||
else container.appendChild(btn);
|
||||
}
|
||||
|
||||
function getItemIdFromUrl() {
|
||||
const queryString = window.location.hash.includes('?') ? window.location.hash.split('?')[1] : window.location.search;
|
||||
const params = new URLSearchParams(queryString);
|
||||
return params.get('id') || params.get('itemId');
|
||||
}
|
||||
|
||||
function createOverlay() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'dialogBackdrop dialogBackdropOpened';
|
||||
overlay.style.zIndex = '99998';
|
||||
overlay.style.backgroundColor = 'rgba(0,0,0,0.7)';
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.right = '0'; overlay.style.bottom = '0';
|
||||
overlay.style.backdropFilter = 'blur(8px)';
|
||||
overlay.style.opacity = '1';
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function createDialogBase(title) {
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'dialog';
|
||||
dialog.style.position = 'fixed';
|
||||
dialog.style.top = '50%'; dialog.style.left = '50%';
|
||||
dialog.style.transform = 'translate(-50%, -50%)';
|
||||
dialog.style.zIndex = '99999';
|
||||
dialog.style.padding = '2.5em';
|
||||
dialog.style.minWidth = '350px';
|
||||
dialog.style.backgroundColor = '#1a1a1a';
|
||||
dialog.style.borderRadius = '1.5em';
|
||||
dialog.style.color = 'white';
|
||||
dialog.style.boxShadow = '0 20px 50px rgba(0,0,0,0.8)';
|
||||
dialog.style.border = '1px solid #444';
|
||||
dialog.style.opacity = '1';
|
||||
|
||||
dialog.innerHTML = `
|
||||
<h2 style="margin-top:0; text-align:center; font-weight:400; color:white; opacity:1;">${title}</h2>
|
||||
<div class="dialog-content" style="margin:1.5em 0; opacity:1;"></div>
|
||||
<div class="dialog-footer" style="display:flex; gap:1em; opacity:1;">
|
||||
<button is="emby-button" class="emby-button button-flat btnCancel" style="flex:1; color: white !important; opacity:1;">Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function showRatingDialog(itemId) {
|
||||
const overlay = createOverlay();
|
||||
const dialog = createDialogBase('Rate on MovieNight');
|
||||
const content = dialog.querySelector('.dialog-content');
|
||||
|
||||
content.innerHTML = `<div class="rating-grid" style="display:grid; grid-template-columns:repeat(5, 1fr); gap:0.8em;"></div>`;
|
||||
const grid = content.querySelector('.rating-grid');
|
||||
|
||||
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button'; btn.is = 'emby-button';
|
||||
btn.className = 'emby-button raised';
|
||||
btn.innerText = i;
|
||||
btn.style.padding = '0.8em 0';
|
||||
btn.style.textAlign = 'center';
|
||||
btn.style.display = 'flex';
|
||||
btn.style.alignItems = 'center';
|
||||
btn.style.justifyContent = 'center';
|
||||
btn.style.fontSize = '1.2em';
|
||||
btn.onclick = async () => { cleanup(); await submitRating(itemId, i); };
|
||||
grid.appendChild(btn);
|
||||
}
|
||||
|
||||
dialog.querySelector('.btnCancel').onclick = cleanup;
|
||||
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
async function showAddMovieDialog() {
|
||||
const overlay = createOverlay();
|
||||
const dialog = createDialogBase('Add Movie (STRM)');
|
||||
const content = dialog.querySelector('.dialog-content');
|
||||
const footer = dialog.querySelector('.dialog-footer');
|
||||
|
||||
content.innerHTML = `
|
||||
<div style="margin-bottom:1em;">
|
||||
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Movie Title (Required)</label>
|
||||
<input type="text" class="emby-input txtTitle" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="e.g. Inception">
|
||||
</div>
|
||||
<div style="display:flex; gap:1em; margin-bottom:1em;">
|
||||
<div style="flex:1;">
|
||||
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Year</label>
|
||||
<input type="number" class="emby-input txtYear" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="2010">
|
||||
</div>
|
||||
<div style="flex:2;">
|
||||
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">IMDb ID</label>
|
||||
<input type="text" class="emby-input txtImdb" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="tt1375666">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Stream URL (Optional)</label>
|
||||
<input type="text" class="emby-input txtUrl" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="http://...">
|
||||
</div>
|
||||
`;
|
||||
|
||||
const btnAdd = document.createElement('button');
|
||||
btnAdd.className = 'emby-button raised button-submit';
|
||||
btnAdd.style.flex = '2';
|
||||
btnAdd.style.backgroundColor = '#0064d2';
|
||||
btnAdd.innerHTML = '<span>Add Film</span>';
|
||||
footer.insertBefore(btnAdd, footer.firstChild);
|
||||
|
||||
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
|
||||
|
||||
btnAdd.onclick = async () => {
|
||||
const title = dialog.querySelector('.txtTitle').value;
|
||||
const year = dialog.querySelector('.txtYear').value;
|
||||
const imdbId = dialog.querySelector('.txtImdb').value;
|
||||
const url = dialog.querySelector('.txtUrl').value;
|
||||
if (!title) return;
|
||||
cleanup();
|
||||
await addMovie(title, url, year, imdbId);
|
||||
};
|
||||
|
||||
dialog.querySelector('.btnCancel').onclick = cleanup;
|
||||
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
dialog.querySelector('.txtTitle').focus();
|
||||
}
|
||||
|
||||
async function showOnboardingDialog() {
|
||||
const overlay = createOverlay();
|
||||
const dialog = createDialogBase('Welcome to MovieNight!');
|
||||
dialog.style.minWidth = '450px';
|
||||
const content = dialog.querySelector('.dialog-content');
|
||||
const footer = dialog.querySelector('.dialog-footer');
|
||||
|
||||
content.innerHTML = `
|
||||
<p style="margin-bottom:1.5em; opacity:0.8; text-align:center;">Pick your preferences to get better recommendations.</p>
|
||||
<div style="margin-bottom:1.5em;">
|
||||
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Favorite Genres</label>
|
||||
<div class="genre-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
|
||||
</div>
|
||||
<div style="margin-bottom:1.5em;">
|
||||
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Preferred Eras</label>
|
||||
<div class="era-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Content Types</label>
|
||||
<div class="type-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const genres = ["Action", "Comedy", "Drama", "Sci-Fi", "Horror", "Thriller", "Animation", "Documentary"];
|
||||
const eras = ["1980s", "1990s", "2000s", "2010s", "2020s"];
|
||||
const types = ["FILM", "SERIES"];
|
||||
|
||||
const selections = { genres: new Set(), eras: new Set(), types: new Set() };
|
||||
|
||||
const createChip = (text, container, type) => {
|
||||
const chip = document.createElement('div');
|
||||
chip.innerText = text;
|
||||
chip.style.cssText = 'padding:0.4em 1em; border-radius:2em; border:1px solid #444; cursor:pointer; font-size:0.9em; transition:all 0.2s;';
|
||||
chip.onclick = () => {
|
||||
if (selections[type].has(text)) {
|
||||
selections[type].delete(text);
|
||||
chip.style.backgroundColor = 'transparent';
|
||||
chip.style.borderColor = '#444';
|
||||
} else {
|
||||
selections[type].add(text);
|
||||
chip.style.backgroundColor = '#0064d2';
|
||||
chip.style.borderColor = '#0064d2';
|
||||
}
|
||||
};
|
||||
container.appendChild(chip);
|
||||
};
|
||||
|
||||
genres.forEach(g => createChip(g, content.querySelector('.genre-chips'), 'genres'));
|
||||
eras.forEach(e => createChip(e, content.querySelector('.era-chips'), 'eras'));
|
||||
types.forEach(t => createChip(t, content.querySelector('.type-chips'), 'types'));
|
||||
|
||||
const btnSave = document.createElement('button');
|
||||
btnSave.className = 'emby-button raised button-submit';
|
||||
btnSave.style.flex = '2';
|
||||
btnSave.style.backgroundColor = '#0064d2';
|
||||
btnSave.innerHTML = '<span>Save & Start</span>';
|
||||
footer.insertBefore(btnSave, footer.firstChild);
|
||||
|
||||
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
|
||||
|
||||
btnSave.onclick = async () => {
|
||||
const payload = {
|
||||
weightedGenres: Object.fromEntries([...selections.genres].map(g => [g, 5])),
|
||||
eras: [...selections.eras],
|
||||
contentTypes: [...selections.types]
|
||||
};
|
||||
cleanup();
|
||||
await completeOnboarding(payload);
|
||||
};
|
||||
|
||||
dialog.querySelector('.btnCancel').onclick = cleanup;
|
||||
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
async function checkOnboarding() {
|
||||
if (window.movieNightOnboardingChecked) return;
|
||||
window.movieNightOnboardingChecked = true;
|
||||
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
const prefs = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Preferences`));
|
||||
if (!prefs || (!Object.keys(prefs.weightedGenres || {}).length && !prefs.eras?.length)) {
|
||||
showOnboardingDialog();
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.status === 404) showOnboardingDialog();
|
||||
}
|
||||
}
|
||||
|
||||
async function completeOnboarding(payload) {
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
try {
|
||||
await ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: ApiClient.getUrl(`MovieNight/Users/${userId}/Onboarding`),
|
||||
data: JSON.stringify(payload),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
showMsg('Welcome! Your preferences have been saved.');
|
||||
} catch (err) {
|
||||
showMsg('Failed to save onboarding preferences.');
|
||||
}
|
||||
}
|
||||
|
||||
async function showRecommendation() {
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
try {
|
||||
const response = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Recommendations`));
|
||||
const recommendations = typeof response === 'string' ? JSON.parse(response) : response;
|
||||
|
||||
if (recommendations && recommendations.length > 0) {
|
||||
const rec = recommendations[0];
|
||||
const film = rec.film || rec;
|
||||
showMsg({
|
||||
title: 'MovieNight Recommendation',
|
||||
text: `How about watching: ${film.title}?\n\nReason: ${rec.reasons?.join(', ') || 'Based on your preferences'}`
|
||||
});
|
||||
} else {
|
||||
showMsg('No recommendations found at the moment.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to get recommendations', err);
|
||||
showMsg('Failed to get recommendations. Check your API token and MovieNight status.');
|
||||
}
|
||||
}
|
||||
|
||||
async function addMovie(title, url, year, imdbId) {
|
||||
try {
|
||||
const data = { title, url };
|
||||
if (year) data.year = parseInt(year);
|
||||
if (imdbId) data.imdbId = imdbId;
|
||||
|
||||
await ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: ApiClient.getUrl(`MovieNight/Films`),
|
||||
data: JSON.stringify(data),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
showMsg(`STRM file created for "${title}". Refresh your library to see it.`);
|
||||
} catch (err) {
|
||||
console.error('Failed to create movie', err);
|
||||
showMsg('Failed to create movie. Ensure STRM output path is configured.');
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerSync() {
|
||||
try {
|
||||
await ApiClient.ajax({ type: 'POST', url: ApiClient.getUrl(`MovieNight/Sync`) });
|
||||
showMsg('Library sync triggered!');
|
||||
setTimeout(updateSyncStatus, 2000);
|
||||
} catch (err) {
|
||||
showMsg('Failed to trigger sync.');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSyncStatus() {
|
||||
const statusEl = document.querySelector('.movieNightSyncStatus');
|
||||
if (!statusEl) return;
|
||||
try {
|
||||
const state = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/SyncState`));
|
||||
if (state && state.lastSyncAt) {
|
||||
statusEl.innerText = `Last sync: ${new Date(state.lastSyncAt).toLocaleString()}`;
|
||||
}
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function submitRating(itemId, score) {
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
try {
|
||||
await ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: ApiClient.getUrl(`MovieNight/Users/${userId}/Ratings/Films/${itemId}`),
|
||||
data: JSON.stringify({ score: parseInt(score), note: 'From Jellyfin UI' }),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
showMsg('Rating submitted to MovieNight!');
|
||||
} catch (err) {
|
||||
showMsg('Failed to submit rating.');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitViewed(itemId) {
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
try {
|
||||
await ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: ApiClient.getUrl(`MovieNight/Users/${userId}/Library/Films/${itemId}/Viewed`),
|
||||
data: JSON.stringify({ watchedAt: new Date().toISOString() }),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
showMsg('Marked as viewed in MovieNight!');
|
||||
} catch (err) {
|
||||
showMsg('Failed to mark as viewed.');
|
||||
}
|
||||
}
|
||||
|
||||
let timeout;
|
||||
const throttledInject = () => {
|
||||
if (timeout) return;
|
||||
timeout = setTimeout(() => {
|
||||
injectUI();
|
||||
timeout = null;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(throttledInject);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
injectUI();
|
||||
})();
|
||||
Reference in New Issue
Block a user