using System; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.MovieNight.Services; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Jellyfin.Plugin.MovieNight.Controllers; /// /// Admin endpoints for the MovieNight plugin. /// [ApiController] [Route("MovieNight")] public class MovieNightController : ControllerBase { private readonly MovieNightBackendClient _backendClient; private readonly MovieNightSyncService _syncService; /// /// Initializes a new instance of the class. /// public MovieNightController( MovieNightBackendClient backendClient, MovieNightSyncService syncService) { _backendClient = backendClient; _syncService = syncService; } /// /// Ping endpoint for connectivity checks. /// [HttpGet("Ping")] public ActionResult Ping() => Ok("Pong"); /// /// Returns plugin status. /// /// Status response. [HttpGet("Status")] [Authorize] public ActionResult GetStatus() { var configuration = Plugin.Instance?.Configuration; return new MovieNightPluginStatus( Enabled: configuration?.Enabled ?? false, BackendBaseUrl: configuration?.BackendBaseUrl ?? string.Empty, PeriodicSyncEnabled: configuration?.EnablePeriodicSync ?? false, PlaybackEventsEnabled: configuration?.EnablePlaybackEvents ?? false, SyncIntervalMinutes: configuration?.SyncIntervalMinutes ?? 30); } /// /// Tests backend connectivity. /// /// Cancellation token. /// Connection result. [HttpPost("TestConnection")] [Authorize] public async Task> TestConnection(CancellationToken cancellationToken) { return await _backendClient.TestConnectionAsync(cancellationToken).ConfigureAwait(false); } /// /// Triggers backend sync. /// /// Cancellation token. /// Backend response. [HttpPost("Sync")] [Authorize] public async Task> Sync(CancellationToken cancellationToken) { await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false); return Ok("Sync triggered"); } /// /// Gets backend sync state. /// /// Cancellation token. /// Backend response. [HttpGet("SyncState")] [Authorize] public async Task SyncState(CancellationToken cancellationToken) { var body = await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false); return Content(body, "application/json"); } /// /// Gets recommendations for the current user. /// [HttpGet("Users/{userId}/Recommendations")] [Authorize] public async Task GetRecommendations( [FromRoute] string userId, [FromQuery] string? contentType, [FromQuery] string? mood, [FromQuery] int limit = 10, CancellationToken cancellationToken = default) { var body = await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false); return Content(body, "application/json"); } /// /// Posts a rating for a film. /// [HttpPost("Users/{userId}/Ratings/Films/{filmId}")] [Authorize] public async Task PostRating( [FromRoute] string userId, [FromRoute] string filmId, [FromBody] RatingRequest request, CancellationToken cancellationToken) { await _backendClient.PostRatingAsync(userId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false); return Ok(); } /// /// Marks a film as viewed. /// [HttpPost("Users/{userId}/Library/Films/{filmId}/Viewed")] [Authorize] public async Task MarkViewed( [FromRoute] string userId, [FromRoute] string filmId, [FromBody] ViewedRequest request, CancellationToken cancellationToken) { await _backendClient.MarkViewedAsync(userId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false); return Ok(); } /// /// Gets user preferences. /// [HttpGet("Users/{userId}/Preferences")] [Authorize] public async Task GetPreferences( [FromRoute] string userId, CancellationToken cancellationToken) { var body = await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false); return body is null ? NotFound() : Content(body, "application/json"); } /// /// Completes onboarding for a user. /// [HttpPost("Users/{userId}/Onboarding")] [Authorize] public async Task CompleteOnboarding( [FromRoute] string userId, [FromBody] object payload, CancellationToken cancellationToken) { await _backendClient.CompleteOnboardingAsync(userId, payload, cancellationToken).ConfigureAwait(false); return Ok(); } /// /// 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 /// [HttpPost("Films")] [Authorize] public async Task CreateFilm([FromBody] CreateFilmRequest request) { var config = Plugin.Instance?.Configuration; if (config == null || string.IsNullOrWhiteSpace(config.StrmOutputPath)) { return BadRequest("STRM output path is not configured."); } if (string.IsNullOrWhiteSpace(request.Title)) { return BadRequest("Movie title is required."); } try { // Construct name: "Movie Name (Year) [imdbid-ttXXXXXXX]" var folderName = request.Title.Trim(); if (request.Year.HasValue) { folderName += $" ({request.Year})"; } if (!string.IsNullOrWhiteSpace(request.ImdbId)) { var ttId = request.ImdbId.Trim().ToLowerInvariant(); if (!ttId.StartsWith("tt")) ttId = "tt" + ttId; folderName += $" [imdbid-{ttId}]"; } // Sanitize for file system var invalidChars = Path.GetInvalidFileNameChars(); var safeFolderName = new string(folderName.Select(c => invalidChars.Contains(c) ? '_' : c).ToArray()); var movieDirectory = Path.Combine(config.StrmOutputPath, safeFolderName); if (!Directory.Exists(movieDirectory)) { Directory.CreateDirectory(movieDirectory); } var filePath = Path.Combine(movieDirectory, $"{safeFolderName}.strm"); var strmContent = string.IsNullOrWhiteSpace(request.Url) ? "http://placeholder.url/upload_me_later" : request.Url.Trim(); await System.IO.File.WriteAllTextAsync(filePath, strmContent).ConfigureAwait(false); return Ok(new { FilePath = filePath, FolderName = safeFolderName }); } catch (Exception ex) { return StatusCode(500, $"Failed to create film: {ex.Message}"); } } } /// /// Create film request. /// public sealed record CreateFilmRequest(string Title, string? Url, int? Year, string? ImdbId); /// /// Rating request. /// public sealed record RatingRequest(int Score, string? Note); /// /// Viewed request. /// public sealed record ViewedRequest(DateTimeOffset? WatchedAt); /// /// MovieNight plugin status response. /// /// Whether integration is enabled. /// Backend base URL. /// Whether periodic sync is enabled. /// Whether playback events are enabled. /// Sync interval in minutes. public sealed record MovieNightPluginStatus( bool Enabled, string BackendBaseUrl, bool PeriodicSyncEnabled, bool PlaybackEventsEnabled, int SyncIntervalMinutes);