diff --git a/README.md b/README.md index 727c279..3cee999 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ The best way to discover and request Movies and TV Shows by using Seerr directly - **Discovery sections**: Carousels sorting movies/tv shows to be discovered by (eg. Trending, Popular, etc.) - **Jellyfin search integration**: Added Seerr movie and TV show results appear in Jellyfin's search to allow you to search and request content directly from Jellyfin - **Request from Seerr**: Easily request movies and TV shows directly in Jellyfin and select quality profiles and specific seasons (for shows) +- **Play what you already have**: Titles already in your Jellyfin library get a Play button in the details popup that opens them directly in Jellyfin - **Quality recommendations**: See the highest released and most common streaming quality when requesting to know the right quality profile for your request - **Requests tab**: Track your Seerr requests with, and open each request open requested content in Seerr, Radarr or Sonarr - **Radarr/Sonarr download progress**: See live download progress of requests to see how far along each request is diff --git a/src/Jellyfin.Plugin.SeerrFin/Controllers/SeerrFinController.cs b/src/Jellyfin.Plugin.SeerrFin/Controllers/SeerrFinController.cs index 1f5515c..1d4ddaf 100644 --- a/src/Jellyfin.Plugin.SeerrFin/Controllers/SeerrFinController.cs +++ b/src/Jellyfin.Plugin.SeerrFin/Controllers/SeerrFinController.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Reflection; using System.Text; using Jellyfin.Plugin.SeerrFin.Configuration; @@ -534,7 +535,25 @@ public ActionResult GetDetails( [FromServices] IUserManager userManager) { JObject? details = _discoveryService.GetMediaDetails(GetUsername(userManager) ?? string.Empty, mediaType, mediaId); - return details == null ? NotFound() : Content(details.ToString(), "application/json"); + if (details == null) + { + return NotFound(); + } + + // Only expose a Jellyfin item the requesting user can actually see in their libraries + JObject? mediaInfo = details["mediaInfo"] as JObject; + int? status = mediaInfo?.Value("status"); + int? status4k = mediaInfo?.Value("status4k"); + if (status is 4 or 5 || status4k is 4 or 5) + { + Guid? jellyfinItemId = _requestsService.ResolveLibraryItemId(GetUserId(), mediaType, mediaId); + if (jellyfinItemId.HasValue) + { + details["jellyfinItemId"] = jellyfinItemId.Value.ToString("N", CultureInfo.InvariantCulture); + } + } + + return Content(details.ToString(), "application/json"); } [HttpGet("justwatch/qualities/{mediaType}/{tmdbId}")] diff --git a/src/Jellyfin.Plugin.SeerrFin/Inject/seerrfin-modal.css b/src/Jellyfin.Plugin.SeerrFin/Inject/seerrfin-modal.css index e284c6e..5e08760 100644 --- a/src/Jellyfin.Plugin.SeerrFin/Inject/seerrfin-modal.css +++ b/src/Jellyfin.Plugin.SeerrFin/Inject/seerrfin-modal.css @@ -21,6 +21,7 @@ } .bst-popout-wrapper button, +.bst-popout-wrapper .bst-btn-play, .bst-popout-wrapper .bst-btn-request, .bst-popout-wrapper .bst-btn-request-4k, .bst-popout-wrapper .bst-btn-trailer, @@ -332,6 +333,7 @@ gap: 1rem; } +.bst-btn-play, .bst-btn-request { display: inline-flex; align-items: center; @@ -351,6 +353,7 @@ } @media (min-width: 640px) { + .bst-btn-play, .bst-btn-request { flex: initial; padding-left: 2rem; @@ -358,10 +361,12 @@ } } +.bst-btn-play:hover, .bst-btn-request:hover { background: #008bff; } +.bst-btn-play:active, .bst-btn-request:active { opacity: 0.85; } diff --git a/src/Jellyfin.Plugin.SeerrFin/Inject/seerrfin-modal.js b/src/Jellyfin.Plugin.SeerrFin/Inject/seerrfin-modal.js index e66c4d3..94cdef7 100644 --- a/src/Jellyfin.Plugin.SeerrFin/Inject/seerrfin-modal.js +++ b/src/Jellyfin.Plugin.SeerrFin/Inject/seerrfin-modal.js @@ -308,7 +308,7 @@ window.seerrFinLog = window.seerrFinLog || { tmdbDetails.mediaInfo = jellyseerrDetails.mediaInfo; } - ['mediaAdded', 'status', 'status4k', 'inProduction'].forEach(function (key) { + ['mediaAdded', 'status', 'status4k', 'inProduction', 'jellyfinItemId'].forEach(function (key) { if (jellyseerrDetails[key] !== undefined && jellyseerrDetails[key] !== null) { tmdbDetails[key] = jellyseerrDetails[key]; } @@ -432,6 +432,23 @@ window.seerrFinLog = window.seerrFinLog || { }); } + function navigateToJellyfinItem(itemId) { + if (!itemId || typeof ApiClient === 'undefined') { + return; + } + + // Only navigate once Jellyfin confirms the current user can see the item + ApiClient.getItem(ApiClient.getCurrentUserId(), itemId) + .then(function (item) { + closeDetailsModal(); + window.Emby.Page.showItem(item); + }) + .catch(function (err) { + log.warn('Jellyfin item lookup failed for ' + itemId, err); + Dashboard.alert('Unable to open this item in Jellyfin'); + }); + } + function loadClientSettings() { return ApiClient.ajax({ url: ApiClient.getUrl('SeerrFin/client-settings'), @@ -1112,6 +1129,12 @@ window.seerrFinLog = window.seerrFinLog || { const logoUrl = getLogoImageUrl(data); const requestState = getRequestButtonState(data, false); const request4kState = getRequestButtonState(data, true); + // The play button takes over the "Available" wording, so the disabled Available button is dropped. + // Other disabled states (Pending, Processing) still carry info and stay. + const showPlay = !!data.jellyfinItemId; + const hideRequest = showPlay && requestState.requested && requestState.label === 'Available'; + const hideRequest4k = showPlay && request4kState.requested && request4kState.label === 'Available'; + const playLabel = (hideRequest || hideRequest4k) ? 'Available' : 'Partially available'; return `
@@ -1143,8 +1166,13 @@ window.seerrFinLog = window.seerrFinLog || {
- - ${getRequestModalAdvanced().showRequest4kButton !== false + ${showPlay + ? `` + : ''} + ${hideRequest + ? '' + : ``} + ${getRequestModalAdvanced().showRequest4kButton !== false && !hideRequest4k ? `` : ''} ${trailerKey @@ -1223,6 +1251,13 @@ window.seerrFinLog = window.seerrFinLog || { }); } + const playBtn = root.querySelector('[data-action="play"]'); + if (playBtn) { + playBtn.addEventListener('click', function () { + navigateToJellyfinItem(playBtn.getAttribute('data-jellyfin-item-id')); + }); + } + const trailerBtn = root.querySelector('[data-action="trailer"]'); if (trailerBtn && trailerKey) { trailerBtn.addEventListener('click', function () { diff --git a/src/Jellyfin.Plugin.SeerrFin/Services/JellyseerrRequestsService.cs b/src/Jellyfin.Plugin.SeerrFin/Services/JellyseerrRequestsService.cs index 999ee39..ecb56f7 100644 --- a/src/Jellyfin.Plugin.SeerrFin/Services/JellyseerrRequestsService.cs +++ b/src/Jellyfin.Plugin.SeerrFin/Services/JellyseerrRequestsService.cs @@ -360,6 +360,17 @@ private JObject MapRequest(JObject req, JObject? details, User? user, Dictionary private static bool IsPlayableMediaStatus(int? mediaStatus) => mediaStatus is 4 or 5; + public Guid? ResolveLibraryItemId(Guid userId, string? type, int tmdbId) + { + User? user = _userManager.GetUserById(userId); + if (user == null) + { + return null; + } + + return ResolveLibraryItemId(user, type, tmdbId, new Dictionary(StringComparer.OrdinalIgnoreCase)); + } + private Guid? ResolveLibraryItemId(User user, string? type, int tmdbId, Dictionary cache) { string cacheKey = $"{type}:{tmdbId}";