Implement user onboarding and enhanced .strm file creation

- Added automated onboarding dialog for new users to pick genres, eras, and content types.
- Enhanced "Add Movie" functionality to support folder-per-movie structure with Year and IMDb ID.
- Improved ui.js with custom dialogs for Onboarding, Rating, and Adding Movies.
- Fixed API accessibility by using standard [Authorize] attributes.
- Added "Mark Viewed" and "Sync" actions to the UI.

Co-authored-by: devitq <118541411+devitq@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot]
2026-05-22 10:45:43 +00:00
co-authored by devitq
parent 4dc806263a
commit b59514a6eb
95 changed files with 4625 additions and 98 deletions
@@ -40,7 +40,10 @@
return btn;
}
function injectUI() {
async function injectUI() {
// Check for onboarding
await checkOnboarding();
// 1. Item Detail Page
const detailButtons = document.querySelector('.mainDetailButtons');
if (detailButtons) {
@@ -231,6 +234,114 @@
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 {
@@ -137,6 +137,32 @@ public class MovieNightController : ControllerBase
return Ok();
}
/// <summary>
/// Gets user preferences.
/// </summary>
[HttpGet("Users/{userId}/Preferences")]
[Authorize]
public async Task<ActionResult<string?>> GetPreferences(
[FromRoute] string userId,
CancellationToken cancellationToken)
{
return await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Completes onboarding for a user.
/// </summary>
[HttpPost("Users/{userId}/Onboarding")]
[Authorize]
public async Task<ActionResult> CompleteOnboarding(
[FromRoute] string userId,
[FromBody] object payload,
CancellationToken cancellationToken)
{
await _backendClient.CompleteOnboardingAsync(userId, payload, cancellationToken).ConfigureAwait(false);
return Ok();
}
/// <summary>
/// Creates a new film by generating a .strm file in a folder-per-movie structure.
/// Structure: Movie Name (Year) [imdbid-ttXXXXXXX]/Movie Name (Year) [imdbid-ttXXXXXXX].strm
@@ -167,6 +167,34 @@ public class MovieNightBackendClient
return body;
}
/// <summary>
/// Gets user preferences.
/// </summary>
public async Task<string?> GetPreferencesAsync(string userId, CancellationToken cancellationToken)
{
var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/preferences");
if (request is null) return null;
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return body;
}
/// <summary>
/// Completes onboarding for a user.
/// </summary>
public async Task CompleteOnboardingAsync(string userId, object payload, CancellationToken cancellationToken)
{
var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/recommendation-onboarding");
if (request is null) return;
request.Content = JsonContent.Create(payload, options: JsonOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
/// <summary>
/// Pushes an event payload to the backend event endpoint.
/// </summary>
-10
View File
@@ -2,16 +2,6 @@
Thin Jellyfin server plugin for bridging Jellyfin playback/sync signals to the MovieNight backend.
## Instructions
Brief instructions on how to integrate this plugin to Jellyfin.
1. Install this plugin
2. Setup plugin in plugin settings
2. Install [JavaScript Inejector plugin](https://github.com/n00bcodr/Jellyfin-JavaScript-Injector)
3. Add [ui.js](./Jellyfin.Plugin.MovieNight/Configuration/ui.js) file to JavaScript Injector
5. You're all set! (i hope)
## Build
```bash