Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion src/Jellyfin.Plugin.SeerrFin/Controllers/SeerrFinController.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Reflection;
using System.Text;
using Jellyfin.Plugin.SeerrFin.Configuration;
Expand Down Expand Up @@ -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<int?>("status");
int? status4k = mediaInfo?.Value<int?>("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}")]
Expand Down
5 changes: 5 additions & 0 deletions src/Jellyfin.Plugin.SeerrFin/Inject/seerrfin-modal.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -332,6 +333,7 @@
gap: 1rem;
}

.bst-btn-play,
.bst-btn-request {
display: inline-flex;
align-items: center;
Expand All @@ -351,17 +353,20 @@
}

@media (min-width: 640px) {
.bst-btn-play,
.bst-btn-request {
flex: initial;
padding-left: 2rem;
padding-right: 2rem;
}
}

.bst-btn-play:hover,
.bst-btn-request:hover {
background: #008bff;
}

.bst-btn-play:active,
.bst-btn-request:active {
opacity: 0.85;
}
Expand Down
41 changes: 38 additions & 3 deletions src/Jellyfin.Plugin.SeerrFin/Inject/seerrfin-modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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 `
<div class="bst-popout-wrapper">
Expand Down Expand Up @@ -1143,8 +1166,13 @@ window.seerrFinLog = window.seerrFinLog || {
<div class="bst-content">
<div class="bst-actions-row">
<div class="bst-actions-left">
<button type="button" class="bst-btn-request" data-action="request"${requestState.requested ? ' disabled' : ''}>${escapeHtml(requestState.label)}</button>
${getRequestModalAdvanced().showRequest4kButton !== false
${showPlay
? `<button type="button" class="bst-btn-play" data-action="play" data-jellyfin-item-id="${escapeHtml(String(data.jellyfinItemId))}">${playLabel}</button>`
: ''}
${hideRequest
? ''
: `<button type="button" class="bst-btn-request" data-action="request"${requestState.requested ? ' disabled' : ''}>${escapeHtml(requestState.label)}</button>`}
${getRequestModalAdvanced().showRequest4kButton !== false && !hideRequest4k
? `<button type="button" class="bst-btn-request-4k" data-action="request-4k"${request4kState.requested ? ' disabled' : ''}>${escapeHtml(request4kState.label)}</button>`
: ''}
${trailerKey
Expand Down Expand Up @@ -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 () {
Expand Down
11 changes: 11 additions & 0 deletions src/Jellyfin.Plugin.SeerrFin/Services/JellyseerrRequestsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Guid?>(StringComparer.OrdinalIgnoreCase));
}

private Guid? ResolveLibraryItemId(User user, string? type, int tmdbId, Dictionary<string, Guid?> cache)
{
string cacheKey = $"{type}:{tmdbId}";
Expand Down