Add optional MDBList movie ratings

This commit is contained in:
ponzischeme89
2026-08-03 08:55:52 +12:00
parent b6b2a9c25a
commit 666da9c5d3
18 changed files with 840 additions and 4 deletions
@@ -611,6 +611,20 @@ class EmbyRepository(private val settings: SettingsStore) {
)
}
/**
* Optional movie ratings authored entirely by the gateway. Direct Emby mode has no
* MDBList configuration, and any gateway failure is deliberately indistinguishable
* from a movie with no external ratings.
*/
suspend fun getMovieRatings(
itemId: String,
): List<com.ponzischeme89.memby.data.model.GatewayMovieRating> {
if (!ServerConfig.isGateway || itemId.isBlank()) return emptyList()
return runCatching { requireGateway().movieRatings(itemId).ratings }
.getOrDefault(emptyList())
.filter { it.name.isNotBlank() && it.score.isNotBlank() && it.scale.isNotBlank() }
}
/** Whether this episode closes its season; failures are non-fatal playback metadata. */
suspend fun seasonFinale(itemId: String): com.ponzischeme89.memby.data.model.GatewaySeasonFinale {
if (itemId.isBlank()) return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
@@ -168,6 +168,21 @@ data class GatewayRows(
val rows: List<HomeRow> = emptyList(),
)
/** Server-formatted external movie rating. The TV renders these fields verbatim and
* never needs to know which providers are enabled or how their scales work. */
@Serializable
data class GatewayMovieRating(
val source: String = "",
val name: String = "",
val score: String = "",
val scale: String = "",
)
@Serializable
data class GatewayMovieRatings(
val ratings: List<GatewayMovieRating> = emptyList(),
)
@Serializable
data class RecommendationOnboarding(
val completed: Boolean = false,
@@ -11,6 +11,7 @@ import com.ponzischeme89.memby.data.model.GatewayLoginRequest
import com.ponzischeme89.memby.data.model.GatewayLoginResponse
import com.ponzischeme89.memby.data.model.GatewayMediaRequest
import com.ponzischeme89.memby.data.model.GatewayMediaRequestResult
import com.ponzischeme89.memby.data.model.GatewayMovieRatings
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
import com.ponzischeme89.memby.data.model.GatewayPlayback
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
@@ -143,6 +144,10 @@ interface GatewayApi {
@GET("v1/items/{id}")
suspend fun item(@Path("id") itemId: String): BaseItem
/** Optional, server-filtered external movie ratings. Empty is always a valid result. */
@GET("v1/items/{id}/ratings")
suspend fun movieRatings(@Path("id") itemId: String): GatewayMovieRatings
@GET("v1/items/{id}/season-finale")
suspend fun seasonFinale(
@Path("id") itemId: String,
@@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -72,6 +74,7 @@ import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayMovieRating
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.TechnicalSpec
@@ -188,6 +191,7 @@ internal fun DetailPageScaffold(
progress: Float = 0f,
progressLabel: String? = null,
reasons: List<String> = emptyList(),
ratings: List<GatewayMovieRating> = emptyList(),
heroActions: List<DetailHeroAction> = emptyList(),
confirmation: String? = null,
pageListState: LazyListState = remember(item.id) { LazyListState() },
@@ -257,6 +261,7 @@ internal fun DetailPageScaffold(
progress = progress,
progressLabel = progressLabel,
reasons = reasons,
ratings = ratings,
actions = heroActions,
actionRequesters = actionRequesters,
height = heroHeight,
@@ -347,6 +352,7 @@ private fun DetailHero(
progress: Float,
progressLabel: String?,
reasons: List<String>,
ratings: List<GatewayMovieRating>,
actions: List<DetailHeroAction>,
actionRequesters: List<FocusRequester>,
height: Dp,
@@ -390,6 +396,10 @@ private fun DetailHero(
}
Spacer(Modifier.height(10.dp))
DetailFactRow(facts = facts, badges = badges, rating = ratingLabel(item))
if (ratings.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
DetailMovieRatings(ratings)
}
if (item.genres.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(
@@ -448,6 +458,49 @@ private fun DetailHero(
}
}
/** Informational only: rating chips deliberately do not take focus, so a remote's
* navigation path remains Play/actions -> tabs. FlowRow wraps narrow layouts while
* keeping every configured and available source visible. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun DetailMovieRatings(
ratings: List<GatewayMovieRating>,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(7.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
ratings.forEach { rating ->
Row(
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(Color(0xB31B2025))
.border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Text(
text = rating.name,
color = DetailQuietText,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
Text(
text = rating.score + rating.scale,
color = DetailText,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
}
}
@Composable
private fun DetailCircularAction(
action: DetailHeroAction,
@@ -22,6 +22,7 @@ import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.RelatedContent
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayMovieRating
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.creditRows
@@ -55,12 +56,20 @@ fun MediaDetailsOverlay(
val settings by ServiceLocator.repository.settingsFlow.collectAsState(initial = Settings.EMPTY)
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
var ratings by remember(item.id) { mutableStateOf<List<GatewayMovieRating>>(emptyList()) }
LaunchedEffect(item.id) {
related = ServiceLocator.repository.getRelated(item)
}
LaunchedEffect(item.id) {
trailer = ServiceLocator.repository.getLocalTrailer(item.id)
}
LaunchedEffect(item.id, item.isMovie) {
ratings = if (item.isMovie) {
ServiceLocator.repository.getMovieRatings(item.id)
} else {
emptyList()
}
}
MediaDetailContent(
item = item,
onPlay = onPlay,
@@ -69,6 +78,7 @@ fun MediaDetailsOverlay(
onOpenItem = onOpenItem,
related = related,
trailer = trailer,
ratings = ratings,
hideWatchedMovies = settings.hideWatchedMovies,
modifier = modifier,
)
@@ -87,6 +97,7 @@ internal fun MediaDetailContent(
modifier: Modifier = Modifier,
related: RelatedContent? = null,
trailer: BaseItem? = null,
ratings: List<GatewayMovieRating> = emptyList(),
hideWatchedMovies: Boolean = false,
onOpenItem: (BaseItem) -> Unit = {},
) {
@@ -167,6 +178,7 @@ internal fun MediaDetailContent(
progressLabel = remainingLabel(item),
reasons = related?.reasons?.takeIf(List<String>::isNotEmpty)
?: listOfNotNull(item.membyRecommendationReason?.takeIf(String::isNotBlank)),
ratings = ratings,
confirmation = confirmation,
onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } },
heroActions = buildList {
@@ -3,6 +3,7 @@ package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayDevices
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
import com.ponzischeme89.memby.data.model.GatewayMovieRatings
import com.ponzischeme89.memby.data.model.GatewayPlayback
import com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
@@ -23,6 +24,16 @@ import org.junit.Test
* side, this fails before a TV ever sees it.
*/
class GatewayPayloadTest {
@Test
fun `decodes server formatted movie rating sources and scales`() {
val response = json.decodeFromString<GatewayMovieRatings>(
"""{"ratings":[{"source":"imdb","name":"IMDb","score":"8.2","scale":"/10"},{"source":"tomatoes","name":"Rotten Tomatoes","score":"91","scale":"%"}]}""",
)
assertEquals(listOf("IMDb", "Rotten Tomatoes"), response.ratings.map { it.name })
assertEquals(listOf("8.2/10", "91%"), response.ratings.map { it.score + it.scale })
}
@Test
fun `decodes signed in devices without an allowance`() {
val response = json.decodeFromString<GatewayDevices>(
+13
View File
@@ -91,6 +91,7 @@ headers attached.
| GET | `/v1/search/history` | Current user's distinct searches from the last 30 days |
| POST | `/v1/search/history` | Record a successful search for the current user |
| GET | `/v1/items/{id}` | Full metadata for one item |
| GET | `/v1/items/{id}/ratings` | Optional server-filtered MDBList movie ratings; empty on disable/unavailable/failure |
| GET | `/v1/items/{id}/episodes` | Complete episode browser for a series, grouped into seasons by the TV |
| GET | `/v1/items/{id}/playback` | Resolves series → episode, returns a direct-play URL |
| GET | `/v1/items/{id}/next` | Episode following this one, or 404 when nothing does |
@@ -302,6 +303,7 @@ bypass the gate after the browser session expires. Scripts may continue to use
| POST | `/admin/api/maintenance` | `{"enabled":true,"message":"…"}` |
| POST | `/admin/api/update-policy` | `{"enabled":true,"latestVersion":"0.1.54","downloadUrl":"…","required":false}` |
| POST | `/admin/api/features` | Publish feature overrides, enter safe mode, reset defaults, or roll back one revision |
| POST | `/admin/api/mdblist-settings` | Enable MDBList, replace/clear its API key, and select visible rating sources |
| GET | `/admin/api/analytics?days=7` | Row engagement |
The **Features** admin page is the recovery surface for optional TV behaviour. Flags are
@@ -316,6 +318,17 @@ so a server never advertises an optional contract to a client that did not decla
The first migrated controls are the Sonarr preroll, automatic My Shows, and its automatic
follow notification; all three can be stopped without publishing an APK.
### Optional MDBList movie ratings
The **Movie ratings** admin page enables MDBList, stores its API key only in the gateway's
`app_settings` table, and selects which supported sources may be returned. It is disabled
by default. Movie detail pages request `/v1/items/{id}/ratings` independently of essential
Emby metadata; missing provider IDs, MDBList errors, invalid or unavailable values all
produce an empty successful response. Successful MDBList responses are cached in Redis for
24 hours before the configured source filter is applied, so changing visible sources does
not consume more MDBList quota. The API key is never returned by either the TV or admin
status APIs.
## Library import
`internal/library` copies Emby's catalogue into Postgres so the gateway answers from its
+3
View File
@@ -23,6 +23,7 @@ import (
"github.com/ponzischeme89/memby/server/internal/foryou"
"github.com/ponzischeme89/memby/server/internal/library"
"github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/mdblist"
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
@@ -90,6 +91,7 @@ func run(log *slog.Logger, events *logging.Buffer) error {
}
embyClient := emby.New(cfg.EmbyURL, cfg.EmbyPublicURL, cfg.ClientName, cfg.UpstreamTimeout)
mdblistClient := mdblist.New(mdblist.DefaultBaseURL, cfg.UpstreamTimeout)
var sonarrClient *sonarr.Client
if cfg.SonarrURL != "" {
sonarrClient = sonarr.New(cfg.SonarrURL, cfg.SonarrAPIKey, cfg.UpstreamTimeout)
@@ -147,6 +149,7 @@ func run(log *slog.Logger, events *logging.Buffer) error {
ForYou: forYouService,
Sonarr: sonarrClient,
Radarr: radarrClient,
MDBList: mdblistClient,
Syncer: syncer,
Log: log,
Events: events,
+92 -3
View File
@@ -42,6 +42,7 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
mux.Handle("POST /admin/api/mdblist-settings", s.adminAuth(s.handleAdminMDBListSettings))
mux.Handle("POST /admin/api/features", s.adminAuth(s.handleAdminFeaturePolicy))
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
@@ -51,7 +52,7 @@ func (s *Server) adminRoutes() http.Handler {
var adminPages = map[string]bool{
"library": true, "recommendations": true, "requests": true,
"features": true, "playback": true, "maintenance": true, "updates": true, "engagement": true,
"imports": true, "logs": true,
"ratings": true, "imports": true, "logs": true,
}
func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
@@ -171,6 +172,7 @@ type adminStatus struct {
ForYouRunning bool `json:"forYouRunning"`
RequestPolicy store.RequestPolicy `json:"requestPolicy"`
PlaybackPolicy store.PlaybackPolicy `json:"playbackPolicy"`
MDBList mdblistAdminSettings `json:"mdblist"`
Features featureResponse `json:"features"`
RequestUsers []store.KnownUser `json:"requestUsers"`
Clients []store.KnownClient `json:"clients"`
@@ -236,11 +238,98 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
Features: featurePayload(s.currentFeaturePolicy(ctx), membyProtocolVersion),
RequestUsers: requestUsers,
Clients: clients,
SonarrReady: s.sonarr != nil,
RadarrReady: s.radarr != nil,
MDBList: func() mdblistAdminSettings {
settings, settingsErr := s.store.MDBListSettings(ctx)
if settingsErr != nil {
s.log.Warn("MDBList settings read failed", "error", settingsErr)
settings = store.DefaultMDBListSettings()
}
return publicMDBListSettings(settings)
}(),
SonarrReady: s.sonarr != nil,
RadarrReady: s.radarr != nil,
})
}
type mdblistAdminSettings struct {
Enabled bool `json:"enabled"`
APIKeyConfigured bool `json:"apiKeyConfigured"`
Sources []string `json:"sources"`
AvailableSources []string `json:"availableSources"`
}
type mdblistSettingsRequest struct {
Enabled bool `json:"enabled"`
APIKey string `json:"apiKey"`
ClearAPIKey bool `json:"clearApiKey"`
Sources []string `json:"sources"`
}
func publicMDBListSettings(settings store.MDBListSettings) mdblistAdminSettings {
return mdblistAdminSettings{
Enabled: settings.Enabled, APIKeyConfigured: settings.APIKey != "",
Sources: settings.Sources, AvailableSources: store.MDBListSources(),
}
}
func (s *Server) handleAdminMDBListSettings(w http.ResponseWriter, r *http.Request) {
var req mdblistSettingsRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
current, err := s.store.MDBListSettings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "could not read MDBList settings")
return
}
apiKey := current.APIKey
if req.ClearAPIKey {
apiKey = ""
} else if replacement := strings.TrimSpace(req.APIKey); replacement != "" {
apiKey = replacement
}
seen := map[string]bool{}
sources := make([]string, 0, len(req.Sources))
for _, source := range req.Sources {
source = strings.ToLower(strings.TrimSpace(source))
if source == "" || seen[source] {
continue
}
if !store.ValidMDBListSource(source) {
writeError(w, http.StatusBadRequest, "unknown MDBList rating source")
return
}
seen[source] = true
sources = append(sources, source)
}
if req.Enabled && apiKey == "" {
writeError(w, http.StatusBadRequest, "set an MDBList API key before enabling ratings")
return
}
if req.Enabled && len(sources) == 0 {
writeError(w, http.StatusBadRequest, "select at least one MDBList rating source")
return
}
if len(sources) == 0 {
sources = current.Sources
}
next := store.MDBListSettings{Enabled: req.Enabled, APIKey: apiKey, Sources: sources}
if err := s.store.SetMDBListSettings(r.Context(), next); err != nil {
s.log.Error("MDBList settings write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save MDBList settings")
return
}
stored, err := s.store.MDBListSettings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "could not reload MDBList settings")
return
}
s.log.Info("MDBList settings changed", "enabled", stored.Enabled, "sources", len(stored.Sources),
"api_key_configured", stored.APIKey != "")
writeJSON(w, http.StatusOK, publicMDBListSettings(stored))
}
type playbackPolicyRequest struct {
PrerollEnabled bool `json:"prerollEnabled"`
PrerollDurationMs int64 `json:"prerollDurationMs"`
+78
View File
@@ -202,6 +202,9 @@
<a class="rail-link" href="/admin/features" data-section="features" title="Features">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6"/></svg><span>Features</span>
</a>
<a class="rail-link" href="/admin/ratings" data-section="ratings" title="Movie ratings">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 2.1 5.4 5.9.4-4.6 3.8 1.5 5.7-4.9-3.2-4.9 3.2 1.5-5.7L4 8.8l5.9-.4L12 3Z"/></svg><span>Movie ratings</span>
</a>
<a class="rail-link" href="/admin/library" data-section="library" title="Library">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5h16v13H4zM8 5.5v13M4 10h4"/></svg><span>Library</span>
</a>
@@ -346,6 +349,36 @@
</table></div>
</section>
<section id="ratings" data-admin-page="ratings">
<h2>MDBList movie ratings</h2>
<p class="muted" style="margin-top:0">
Optionally enrich movie details with ratings fetched by the gateway. The API key
stays on this server, responses are cached for 24 hours, and failures never block a TV.
</p>
<div class="row" style="margin-bottom:14px">
<label style="display:flex;align-items:center;gap:9px">
<input type="checkbox" id="mdblist-enabled">
<span>Show external movie ratings</span>
</label>
<span id="mdblist-state" class="pill muted">off</span>
</div>
<div style="display:grid;gap:8px;margin-bottom:16px">
<label class="muted" for="mdblist-api-key">MDBList API key</label>
<div class="row">
<input type="password" id="mdblist-api-key" autocomplete="new-password" placeholder="Paste an API key">
<label style="display:flex;align-items:center;gap:7px">
<input type="checkbox" id="mdblist-clear-key"> Remove saved key
</label>
</div>
<span class="muted" style="font-size:12px">Leave the field blank to keep the currently saved key.</span>
</div>
<h2 style="margin-bottom:8px">Sources shown on TVs</h2>
<div id="mdblist-sources" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:9px;margin-bottom:16px">
<span class="muted">Loading sources…</span>
</div>
<button id="mdblist-save">Save ratings settings</button>
</section>
<section id="playback" data-admin-page="playback">
<h2>Playback experience</h2>
<p class="muted" style="margin-top:0">
@@ -458,6 +491,7 @@ const pageCopy = {
recommendations: ['Recommendations', 'Pressure-test personalised rows and title scores per user.'],
requests: ['Media requests', 'Control who can request missing movies and shows.'],
features: ['Features', 'Roll out, stop and recover optional TV behaviour from the server.'],
ratings: ['Movie ratings', 'Configure optional server-side MDBList ratings for movie details.'],
playback: ['Playback', 'Control server-driven playback presentation on every TV.'],
maintenance: ['Maintenance', 'Control gateway availability for every television.'],
updates: ['App updates', 'Publish optional or mandatory client update policy.'],
@@ -643,6 +677,33 @@ function renderStatus(status) {
renderFeatures(status.features || {}, status.clients || []);
const mdblist = status.mdblist || {};
const mdblistEnabled = document.getElementById('mdblist-enabled');
if (document.activeElement !== mdblistEnabled) mdblistEnabled.checked = Boolean(mdblist.enabled);
const mdblistState = document.getElementById('mdblist-state');
mdblistState.textContent = mdblist.enabled
? 'on · ' + ((mdblist.sources || []).length) + ' sources'
: (mdblist.apiKeyConfigured ? 'off · key saved' : 'off · no key');
mdblistState.className = 'pill ' + (mdblist.enabled ? 'ok' : 'muted');
const keyField = document.getElementById('mdblist-api-key');
keyField.placeholder = mdblist.apiKeyConfigured ? 'Saved key (leave blank to keep)' : 'Paste an API key';
const sourceNames = {
imdb:'IMDb', tomatoes:'Rotten Tomatoes', audience:'Rotten Tomatoes Audience',
metacritic:'Metacritic', letterboxd:'Letterboxd', rogerebert:'Roger Ebert',
tmdb:'TMDb', trakt:'Trakt', mal:'MyAnimeList',
score:'MDBList Score', score_average:'MDBList Average',
};
const sourceBox = document.getElementById('mdblist-sources');
if (!sourceBox.contains(document.activeElement)) {
const selectedSources = new Set(mdblist.sources || []);
sourceBox.innerHTML = (mdblist.availableSources || []).map((source) =>
'<label style="display:flex;align-items:center;gap:8px">' +
'<input type="checkbox" data-mdblist-source="' + escapeHtml(source) + '"' +
(selectedSources.has(source) ? ' checked' : '') + '>' +
'<span>' + escapeHtml(sourceNames[source] || source) + '</span></label>'
).join('') || '<span class="muted">No rating sources available.</span>';
}
const playback = status.playbackPolicy || {};
const prerollEnabled = document.getElementById('preroll-enabled');
const prerollDuration = document.getElementById('preroll-duration');
@@ -964,6 +1025,23 @@ document.getElementById('playback-save').addEventListener('click', () => {
}));
});
document.getElementById('mdblist-save').addEventListener('click', () => {
const sources = [...document.querySelectorAll('[data-mdblist-source]:checked')]
.map((box) => box.dataset.mdblistSource);
act(() => api('/admin/api/mdblist-settings', {
method: 'POST',
body: JSON.stringify({
enabled: document.getElementById('mdblist-enabled').checked,
apiKey: document.getElementById('mdblist-api-key').value.trim(),
clearApiKey: document.getElementById('mdblist-clear-key').checked,
sources,
}),
}).then(() => {
document.getElementById('mdblist-api-key').value = '';
document.getElementById('mdblist-clear-key').checked = false;
}));
});
function updatePolicyBody(enabled) {
return JSON.stringify({
enabled,
+14 -1
View File
@@ -323,7 +323,7 @@ func TestAdminPagesUseRealRoutes(t *testing.T) {
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
for _, page := range []string{
"library", "recommendations", "requests", "maintenance",
"library", "recommendations", "requests", "ratings", "maintenance",
"updates", "engagement", "imports", "logs",
} {
req := httptest.NewRequest(http.MethodGet, "/admin/"+page, nil)
@@ -349,6 +349,19 @@ func TestAdminPagesUseRealRoutes(t *testing.T) {
}
}
func TestMDBListAdminStatusNeverExposesTheAPIKey(t *testing.T) {
view := publicMDBListSettings(store.MDBListSettings{
Enabled: true, APIKey: "super-secret", Sources: []string{"imdb"},
})
body, err := json.Marshal(view)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(body), "super-secret") || !strings.Contains(string(body), `"apiKeyConfigured":true`) {
t.Fatalf("unsafe MDBList admin payload: %s", body)
}
}
func TestInstallerDestinationAllowsOnlyKnownAdminPages(t *testing.T) {
if got := cleanInstallerDestination("/admin/recommendations"); got != "/admin/recommendations" {
t.Fatalf("recommendation destination = %q", got)
+6
View File
@@ -27,6 +27,7 @@ import (
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/foryou"
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/mdblist"
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
@@ -42,11 +43,13 @@ type Server struct {
forYou *foryou.Service
sonarr *sonarr.Client
radarr *radarr.Client
mdblist *mdblist.Client
syncer syncerHandle
log *slog.Logger
events *serverlogging.Buffer
sonarrMu sync.Mutex
radarrMu sync.Mutex
mdblistMu sync.Mutex
// alertMu serialises the read-modify-write of the shared alert list. Its producers
// are events — a webhook, a finished sync, a health probe — none of them paced by
// this server, so two can land at once.
@@ -67,6 +70,7 @@ type Deps struct {
ForYou *foryou.Service
Sonarr *sonarr.Client
Radarr *radarr.Client
MDBList *mdblist.Client
Syncer syncerHandle
Log *slog.Logger
Events *serverlogging.Buffer
@@ -82,6 +86,7 @@ func New(cfg config.Config, deps Deps) *Server {
forYou: deps.ForYou,
sonarr: deps.Sonarr,
radarr: deps.Radarr,
mdblist: deps.MDBList,
syncer: deps.Syncer,
log: deps.Log,
events: deps.Events,
@@ -123,6 +128,7 @@ func (s *Server) Routes() http.Handler {
v1.Handle("GET /v1/features", s.authed(s.handleFeatures))
v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem))
v1.Handle("GET /v1/items/{id}/ratings", s.authed(s.handleMovieRatings))
v1.Handle("GET /v1/items/{id}/season-finale", s.authed(s.handleSeasonFinale))
v1.Handle("GET /v1/items/{id}/episodes", s.authed(s.handleSeriesEpisodes))
v1.Handle("GET /v1/items/{id}/related", s.authed(s.handleRelated))
+210
View File
@@ -0,0 +1,210 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/mdblist"
"github.com/ponzischeme89/memby/server/internal/store"
)
const mdblistRatingsTTL = 24 * time.Hour
type movieRating struct {
Source string `json:"source"`
Name string `json:"name"`
Score string `json:"score"`
Scale string `json:"scale"`
}
type movieRatingsResponse struct {
Ratings []movieRating `json:"ratings"`
}
type ratingSource struct {
Name string
Scale string
Maximum float64
}
var movieRatingSources = map[string]ratingSource{
"imdb": {Name: "IMDb", Scale: "/10", Maximum: 10},
"tomatoes": {Name: "Rotten Tomatoes", Scale: "%", Maximum: 100},
"audience": {Name: "Rotten Tomatoes Audience", Scale: "%", Maximum: 100},
"metacritic": {Name: "Metacritic", Scale: "/100", Maximum: 100},
"letterboxd": {Name: "Letterboxd", Scale: "/5", Maximum: 5},
"rogerebert": {Name: "Roger Ebert", Scale: "/4", Maximum: 4},
"tmdb": {Name: "TMDb", Scale: "/10", Maximum: 10},
"trakt": {Name: "Trakt", Scale: "%", Maximum: 100},
"mal": {Name: "MyAnimeList", Scale: "/10", Maximum: 10},
"score": {Name: "MDBList Score", Scale: "/100", Maximum: 100},
"score_average": {
Name: "MDBList Average", Scale: "/100", Maximum: 100,
},
}
var movieRatingAliases = map[string]string{
"imdb": "imdb", "tomatoes": "tomatoes", "rottentomatoes": "tomatoes",
"rtomatoes": "tomatoes", "rttomatoes": "tomatoes",
"audience": "audience", "tomatoesaudience": "audience", "rtaudience": "audience",
"metacritic": "metacritic", "letterboxd": "letterboxd",
"rogerebert": "rogerebert", "roger_ebert": "rogerebert",
"tmdb": "tmdb", "trakt": "trakt", "mal": "mal", "myanimelist": "mal",
"score": "score", "score_average": "score_average", "scoreaverage": "score_average",
}
type ratingsEmbyItem struct {
Type string `json:"Type"`
ProviderIDs map[string]string `json:"ProviderIds"`
}
// handleMovieRatings is deliberately separate from handleItem. External ratings arrive
// after the essential metadata, and every failure path returns an empty successful
// response so MDBList can never prevent a detail page from opening.
func (s *Server) handleMovieRatings(w http.ResponseWriter, r *http.Request, sess store.Session) {
empty := movieRatingsResponse{Ratings: []movieRating{}}
itemID := strings.TrimSpace(r.PathValue("id"))
if itemID == "" {
writeJSON(w, http.StatusOK, empty)
return
}
if s.store == nil || s.emby == nil || s.cache == nil || s.mdblist == nil {
writeJSON(w, http.StatusOK, empty)
return
}
settings, err := s.store.MDBListSettings(r.Context())
if err != nil || !settings.Enabled || settings.APIKey == "" || len(settings.Sources) == 0 {
if err != nil && s.log != nil {
s.log.Warn("MDBList settings unavailable", "error", err)
}
writeJSON(w, http.StatusOK, empty)
return
}
rawItem, err := s.emby.Item(r.Context(), credentials(sess), itemID, "ProviderIds")
if err != nil {
s.logMDBListFailure("movie identifiers unavailable", itemID, err)
writeJSON(w, http.StatusOK, empty)
return
}
var item ratingsEmbyItem
if json.Unmarshal(rawItem, &item) != nil || !strings.EqualFold(item.Type, "Movie") {
writeJSON(w, http.StatusOK, empty)
return
}
provider, providerID := movieProvider(item.ProviderIDs)
if providerID == "" {
writeJSON(w, http.StatusOK, empty)
return
}
ratings, err := s.loadMDBListRatings(r.Context(), settings.APIKey, provider, providerID)
if err != nil {
s.logMDBListFailure("ratings unavailable", itemID, err)
writeJSON(w, http.StatusOK, empty)
return
}
writeJSON(w, http.StatusOK, movieRatingsResponse{
Ratings: selectedMovieRatings(settings.Sources, ratings),
})
}
func (s *Server) loadMDBListRatings(
ctx context.Context, apiKey, provider, providerID string,
) ([]mdblist.Rating, error) {
key := "mdblist:movie-ratings:v1:" + provider + ":" + providerID
if ratings, ok := s.cachedMDBListRatings(ctx, key); ok {
return ratings, nil
}
// A viewer can move focus rapidly and open the same title before the first request
// finishes. Double-checking under the lock keeps that from spending quota twice.
s.mdblistMu.Lock()
defer s.mdblistMu.Unlock()
if ratings, ok := s.cachedMDBListRatings(ctx, key); ok {
return ratings, nil
}
ratings, err := s.mdblist.Movie(ctx, apiKey, provider, providerID)
if err != nil {
return nil, err
}
if ratings == nil {
ratings = []mdblist.Rating{}
}
if raw, marshalErr := json.Marshal(ratings); marshalErr == nil {
if cacheErr := s.cache.Set(ctx, key, raw, mdblistRatingsTTL); cacheErr != nil && s.log != nil {
s.log.Warn("MDBList rating cache write failed", "error", cacheErr)
}
}
return ratings, nil
}
func (s *Server) cachedMDBListRatings(ctx context.Context, key string) ([]mdblist.Rating, bool) {
raw, err := s.cache.Get(ctx, key)
if err != nil {
return nil, false
}
var ratings []mdblist.Rating
if json.Unmarshal(raw, &ratings) != nil {
return nil, false
}
if ratings == nil {
ratings = []mdblist.Rating{}
}
return ratings, true
}
func (s *Server) logMDBListFailure(message, itemID string, err error) {
if s.log != nil {
s.log.Debug("MDBList "+message, "item", itemID, "error", err)
}
}
func movieProvider(ids map[string]string) (string, string) {
if id := strings.TrimSpace(providerID(ids, "tmdb")); id != "" {
return "tmdb", id
}
if id := strings.TrimSpace(providerID(ids, "imdb")); id != "" {
return "imdb", id
}
return "", ""
}
func selectedMovieRatings(selected []string, available []mdblist.Rating) []movieRating {
values := make(map[string]float64, len(available))
for _, rating := range available {
canonical := movieRatingAliases[strings.ToLower(strings.TrimSpace(rating.Source))]
source, known := movieRatingSources[canonical]
if !known || rating.Value <= 0 || rating.Value > source.Maximum {
continue
}
if _, exists := values[canonical]; !exists {
values[canonical] = rating.Value
}
}
result := make([]movieRating, 0, len(selected))
seen := map[string]bool{}
for _, id := range selected {
id = strings.ToLower(strings.TrimSpace(id))
source, known := movieRatingSources[id]
value, available := values[id]
if !known || !available || seen[id] {
continue
}
seen[id] = true
result = append(result, movieRating{
Source: id, Name: source.Name, Score: formatRatingScore(value), Scale: source.Scale,
})
}
if result == nil {
return []movieRating{}
}
return result
}
func formatRatingScore(value float64) string {
return strings.TrimRight(strings.TrimRight(strconv.FormatFloat(value, 'f', 2, 64), "0"), ".")
}
+39
View File
@@ -0,0 +1,39 @@
package api
import (
"testing"
"github.com/ponzischeme89/memby/server/internal/mdblist"
)
func TestSelectedMovieRatingsFiltersOrdersAndLabelsAvailableValues(t *testing.T) {
got := selectedMovieRatings(
[]string{"letterboxd", "imdb", "tomatoes", "rogerebert", "metacritic"},
[]mdblist.Rating{
{Source: "imdb", Value: 8.2},
{Source: "rttomatoes", Value: 91},
{Source: "letterboxd", Value: 4.15},
{Source: "rogerebert", Value: 0},
{Source: "metacritic", Value: 101},
},
)
if len(got) != 3 {
t.Fatalf("ratings = %+v", got)
}
if got[0] != (movieRating{Source: "letterboxd", Name: "Letterboxd", Score: "4.15", Scale: "/5"}) ||
got[1].Name != "IMDb" || got[1].Score != "8.2" || got[1].Scale != "/10" ||
got[2].Name != "Rotten Tomatoes" || got[2].Score != "91" || got[2].Scale != "%" {
t.Fatalf("formatted ratings = %+v", got)
}
}
func TestMovieProviderPrefersTMDBAndFallsBackToIMDb(t *testing.T) {
provider, id := movieProvider(map[string]string{"ImDb": "tt0111161", "TmDb": "278"})
if provider != "tmdb" || id != "278" {
t.Fatalf("provider = %q %q", provider, id)
}
provider, id = movieProvider(map[string]string{"IMDb": "tt0111161"})
if provider != "imdb" || id != "tt0111161" {
t.Fatalf("fallback provider = %q %q", provider, id)
}
}
+131
View File
@@ -0,0 +1,131 @@
// Package mdblist reads third-party movie ratings without exposing the operator's
// MDBList API key to a television.
package mdblist
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const DefaultBaseURL = "https://api.mdblist.com"
type Client struct {
baseURL string
http *http.Client
}
// Rating is one value exactly as returned by MDBList. Presentation names and scales
// are assigned by the gateway, where the operator's selected sources also live.
type Rating struct {
Source string `json:"source"`
Value float64 `json:"value"`
}
type mediaResponse struct {
Ratings []rawRating `json:"ratings"`
}
type rawRating struct {
Source string `json:"source"`
Value json.RawMessage `json:"value"`
}
type APIError struct {
StatusCode int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("mdblist: status %d: %s", e.StatusCode, e.Body)
}
func New(baseURL string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
MaxIdleConns: 20,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
},
}
}
// Movie resolves a movie through one of the identifier providers accepted by MDBList.
// The single-title endpoint returns every rating source available for that title in one
// request, which lets source selection happen locally without spending more API quota.
func (c *Client) Movie(
ctx context.Context, apiKey, provider, providerID string,
) ([]Rating, error) {
apiKey = strings.TrimSpace(apiKey)
provider = strings.ToLower(strings.TrimSpace(provider))
providerID = strings.TrimSpace(providerID)
if apiKey == "" || providerID == "" || (provider != "tmdb" && provider != "imdb") {
return nil, fmt.Errorf("mdblist: invalid movie lookup")
}
endpoint, err := url.Parse(c.baseURL + "/" + provider + "/movie/" + url.PathEscape(providerID) + "/")
if err != nil {
return nil, fmt.Errorf("mdblist: build request: %w", err)
}
query := endpoint.Query()
query.Set("apikey", apiKey)
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmt.Errorf("mdblist: build request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Memby gateway")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("mdblist: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return nil, &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
}
var media mediaResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&media); err != nil {
return nil, fmt.Errorf("mdblist: decode response: %w", err)
}
result := make([]Rating, 0, len(media.Ratings))
for _, candidate := range media.Ratings {
value, ok := numericValue(candidate.Value)
if !ok || strings.TrimSpace(candidate.Source) == "" {
continue
}
result = append(result, Rating{Source: candidate.Source, Value: value})
}
return result, nil
}
func numericValue(raw json.RawMessage) (float64, bool) {
text := strings.TrimSpace(string(raw))
if text == "" || text == "null" {
return 0, false
}
if len(text) >= 2 && text[0] == '"' && text[len(text)-1] == '"' {
var value string
if json.Unmarshal(raw, &value) != nil {
return 0, false
}
text = strings.TrimSpace(value)
}
value, err := strconv.ParseFloat(text, 64)
if err != nil || math.IsNaN(value) || math.IsInf(value, 0) {
return 0, false
}
return value, true
}
+42
View File
@@ -0,0 +1,42 @@
package mdblist
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestMovieReadsAllAvailableNumericRatings(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/tmdb/movie/278/" || r.URL.Query().Get("apikey") != "secret" {
t.Fatalf("unexpected request %s?%s", r.URL.Path, r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ratings":[
{"source":"imdb","value":9.3,"score":93},
{"source":"letterboxd","value":"4.6"},
{"source":"metacritic","value":null},
{"source":"tomatoes","value":"not available"}
]}`))
}))
defer server.Close()
got, err := New(server.URL, time.Second).Movie(context.Background(), "secret", "tmdb", "278")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Source != "imdb" || got[0].Value != 9.3 ||
got[1].Source != "letterboxd" || got[1].Value != 4.6 {
t.Fatalf("ratings = %+v", got)
}
}
func TestMovieRejectsUnsupportedProviderBeforeCallingUpstream(t *testing.T) {
_, err := New("https://example.invalid", time.Second).
Movie(context.Background(), "secret", "tvdb", "42")
if err == nil {
t.Fatal("expected unsupported provider to fail")
}
}
+88
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -21,6 +22,93 @@ const RequestPolicyKey = "request_policy"
// shipping a new TV build.
const PlaybackPolicyKey = "playback_policy"
// MDBListSettingsKey stores the optional movie-ratings integration. The API key stays
// in this server-owned document and is never included in client or admin status payloads.
const MDBListSettingsKey = "mdblist_settings"
var defaultMDBListSources = []string{
"imdb", "tomatoes", "audience", "metacritic", "letterboxd", "rogerebert",
"tmdb", "trakt", "mal", "score", "score_average",
}
type MDBListSettings struct {
Enabled bool `json:"enabled"`
APIKey string `json:"apiKey"`
Sources []string `json:"sources"`
UpdatedAt time.Time `json:"updatedAt"`
}
func DefaultMDBListSettings() MDBListSettings {
return MDBListSettings{Sources: append([]string(nil), defaultMDBListSources...)}
}
func MDBListSources() []string {
return append([]string(nil), defaultMDBListSources...)
}
func ValidMDBListSource(source string) bool {
source = strings.ToLower(strings.TrimSpace(source))
for _, supported := range defaultMDBListSources {
if source == supported {
return true
}
}
return false
}
func normalizeMDBListSettings(settings MDBListSettings) MDBListSettings {
settings.APIKey = strings.TrimSpace(settings.APIKey)
seen := map[string]bool{}
sources := make([]string, 0, len(settings.Sources))
for _, source := range settings.Sources {
source = strings.ToLower(strings.TrimSpace(source))
if !ValidMDBListSource(source) || seen[source] {
continue
}
seen[source] = true
sources = append(sources, source)
}
if len(sources) == 0 {
sources = append(sources, defaultMDBListSources...)
}
settings.Sources = sources
return settings
}
func (s *Store) MDBListSettings(ctx context.Context) (MDBListSettings, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, MDBListSettingsKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return DefaultMDBListSettings(), nil
}
if err != nil {
return DefaultMDBListSettings(), fmt.Errorf("store: read MDBList settings: %w", err)
}
var settings MDBListSettings
if err := json.Unmarshal(raw, &settings); err != nil {
return DefaultMDBListSettings(), fmt.Errorf("store: decode MDBList settings: %w", err)
}
return normalizeMDBListSettings(settings), nil
}
func (s *Store) SetMDBListSettings(ctx context.Context, settings MDBListSettings) error {
settings = normalizeMDBListSettings(settings)
settings.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(settings)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
MDBListSettingsKey, string(raw))
if err != nil {
return fmt.Errorf("store: write MDBList settings: %w", err)
}
return nil
}
// FeaturePolicyKey is the durable operator control plane for optional behaviour.
// The catalogue of valid flags lives in the API; the store only persists overrides so
// removing or renaming a feature does not strand an unreadable database row.
+14
View File
@@ -25,6 +25,20 @@ func TestPlaybackPolicyDefaultsAndClampsDuration(t *testing.T) {
}
}
func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
defaults := DefaultMDBListSettings()
if defaults.Enabled || defaults.APIKey != "" || len(defaults.Sources) == 0 {
t.Fatalf("default MDBList settings = %+v", defaults)
}
got := normalizeMDBListSettings(MDBListSettings{
APIKey: " secret ", Sources: []string{"IMDb", "imdb", "unknown", "letterboxd"},
})
if got.APIKey != "secret" || len(got.Sources) != 2 ||
got.Sources[0] != "imdb" || got.Sources[1] != "letterboxd" {
t.Fatalf("normalized MDBList settings = %+v", got)
}
}
func TestFeaturePolicyDefaultsAreRecoverable(t *testing.T) {
policy := normalizeFeaturePolicy(FeaturePolicy{})
if policy.Overrides == nil || policy.SafeMode || policy.Revision != 0 {