diff --git a/frontend/static/css/requestarr-users.css b/frontend/static/css/requestarr-users.css
index ac8fc0d2..fc57f78e 100644
--- a/frontend/static/css/requestarr-users.css
+++ b/frontend/static/css/requestarr-users.css
@@ -186,11 +186,6 @@
color: #eab308;
}
-.requsers-role-admin {
- background: rgba(168, 85, 247, 0.15);
- color: #a855f7;
-}
-
.requsers-role-user {
background: rgba(99, 102, 241, 0.15);
color: #818cf8;
@@ -719,3 +714,43 @@
padding-left: 16px;
font-size: 0.9rem;
}
+
+/* ── Hidden Media Scope Badges ─────────────────────────────── */
+
+.hidden-scope-badge {
+ position: absolute;
+ top: 6px;
+ left: 6px;
+ padding: 2px 7px;
+ border-radius: 8px;
+ font-size: 0.6rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ z-index: 2;
+}
+
+.hidden-scope-global {
+ background: rgba(234, 179, 8, 0.85);
+ color: #000;
+}
+
+.hidden-scope-personal {
+ background: rgba(99, 102, 241, 0.85);
+ color: #fff;
+}
+
+/* ── Non-owner mode: hide everything outside Requests via CSS !important ── */
+/* This is the nuclear option — no JS race condition can override these rules */
+
+body.non-owner-mode #homeNav,
+body.non-owner-mode #nav-group-core-label,
+body.non-owner-mode #nav-group-system,
+body.non-owner-mode #nav-group-apps-label,
+body.non-owner-mode #nav-group-media-hunt,
+body.non-owner-mode #nzb-hunt-sidebar-group,
+body.non-owner-mode #nav-group-apps,
+body.non-owner-mode .requestarr-header-bar,
+body.non-owner-mode .sidebar-footer {
+ display: none !important;
+}
diff --git a/frontend/static/js/app.js b/frontend/static/js/app.js
index 2151a11c..68947850 100644
--- a/frontend/static/js/app.js
+++ b/frontend/static/js/app.js
@@ -85,16 +85,20 @@ let huntarrUI = {
this._enableMediaHunt = generalSettings.enable_media_hunt !== false;
this._enableThirdPartyApps = generalSettings.enable_third_party_apps !== false;
// Update sidebar group visibility from database settings (nav-group-* IDs)
- var requestsGroup = document.getElementById('nav-group-requests');
- var mediaHuntGroup = document.getElementById('nav-group-media-hunt');
- var nzbHuntGroup = document.getElementById('nzb-hunt-sidebar-group');
- var appsGroup = document.getElementById('nav-group-apps');
- var appsLabel = document.getElementById('nav-group-apps-label');
- if (requestsGroup) requestsGroup.style.display = (generalSettings.enable_requestarr === false) ? 'none' : '';
- if (mediaHuntGroup) mediaHuntGroup.style.display = (generalSettings.enable_media_hunt === false) ? 'none' : '';
- if (nzbHuntGroup) nzbHuntGroup.style.display = (generalSettings.enable_media_hunt === false) ? 'none' : '';
- if (appsGroup) appsGroup.style.display = (generalSettings.enable_third_party_apps === false) ? 'none' : '';
- if (appsLabel) appsLabel.style.display = (generalSettings.enable_media_hunt === false && generalSettings.enable_third_party_apps === false) ? 'none' : '';
+ // IMPORTANT: Skip this for non-owner users — they are fully siloed
+ var isNonOwner = document.body.classList.contains('non-owner-mode');
+ if (!isNonOwner) {
+ var requestsGroup = document.getElementById('nav-group-requests');
+ var mediaHuntGroup = document.getElementById('nav-group-media-hunt');
+ var nzbHuntGroup = document.getElementById('nzb-hunt-sidebar-group');
+ var appsGroup = document.getElementById('nav-group-apps');
+ var appsLabel = document.getElementById('nav-group-apps-label');
+ if (requestsGroup) requestsGroup.style.display = (generalSettings.enable_requestarr === false) ? 'none' : '';
+ if (mediaHuntGroup) mediaHuntGroup.style.display = (generalSettings.enable_media_hunt === false) ? 'none' : '';
+ if (nzbHuntGroup) nzbHuntGroup.style.display = (generalSettings.enable_media_hunt === false) ? 'none' : '';
+ if (appsGroup) appsGroup.style.display = (generalSettings.enable_third_party_apps === false) ? 'none' : '';
+ if (appsLabel) appsLabel.style.display = (generalSettings.enable_media_hunt === false && generalSettings.enable_third_party_apps === false) ? 'none' : '';
+ }
if (typeof window.applyFeatureFlags === 'function') window.applyFeatureFlags();
// Initialize originalSettings early
@@ -276,15 +280,8 @@ let huntarrUI = {
this._pendingBadgeInterval = setInterval(() => this._updatePendingRequestBadge(), 60000);
}
} else {
- // Admin and User roles: siloed to Requests only
+ // Non-owner users: siloed to Requests only
this._applyNonOwnerRestrictions();
- // Admins get the pending badge too
- if (this._userRole === 'admin') {
- this._updatePendingRequestBadge();
- if (!this._pendingBadgeInterval) {
- this._pendingBadgeInterval = setInterval(() => this._updatePendingRequestBadge(), 60000);
- }
- }
}
})
.catch(e => {
@@ -310,96 +307,74 @@ let huntarrUI = {
},
/**
- * Non-owner users (admin + user) are siloed to Requests only.
- * - Hide Home, Core label, System, Apps label, Media Hunt, NZB Hunt, 3rd Party Apps, Sponsors
+ * Non-owner users are siloed to Requests only.
+ * - Hide Home, Core label, System, Apps label, Media Hunt, NZB Hunt, 3rd Party Apps
* - Hide the Requests group header (no accordion) — show sub-items as flat top-level nav
- * - For 'user' role: also hide admin-only items within Requests (Users, Services, Requests mgmt, Settings, Smart Hunt)
+ * - Hide owner-only items within Requests (Users, Services, Requests mgmt, Settings, Smart Hunt)
*/
_applyNonOwnerRestrictions: function() {
- var role = this._userRole;
+ // 1. Mark body FIRST so CSS !important rules take effect immediately
+ // This prevents any async settings fetch from re-showing hidden elements
+ document.body.classList.add('non-owner-mode');
- // 1. Hide everything outside Requests
- var hideElements = [
- 'homeNav', // Home button
- 'nav-group-core-label', // "Core" label
- 'nav-group-system', // System group
- 'nav-group-apps-label', // "Apps" label
- 'nav-group-media-hunt', // Media Hunt
- 'nzb-hunt-sidebar-group', // NZB Hunt
- 'nav-group-apps', // 3rd Party Apps
- 'main-sidebar-partner-projects-group', // Sponsors
- ];
- hideElements.forEach(function(id) {
- var el = document.getElementById(id);
- if (el) el.style.display = 'none';
- });
-
- // 2. Hide the Requests group header (accordion toggle) — make sub-items flat
+ // 2. Inject "Request System" label above the Requests group items
var requestsGroup = document.getElementById('nav-group-requests');
+ if (requestsGroup && !document.getElementById('non-owner-label')) {
+ var label = document.createElement('div');
+ label.id = 'non-owner-label';
+ label.className = 'nav-group';
+ label.innerHTML = '
Request System
';
+ requestsGroup.parentNode.insertBefore(label, requestsGroup);
+ }
+
+ // 3. Hide the Requests group header (accordion toggle) — make sub-items flat
if (requestsGroup) {
var header = requestsGroup.querySelector('.nav-group-header');
if (header) header.style.display = 'none';
}
- // 3. Force-expand the Requests group body and promote items to top-level styling
+ // 4. Force-expand the Requests group body and promote items to top-level styling
var requestsBody = document.getElementById('sidebar-group-requests');
if (requestsBody) {
requestsBody.classList.remove('collapsed');
requestsBody.classList.add('non-owner-flat');
}
- // 4. For 'user' role, hide admin-only items within Requests
- if (role === 'user') {
- var hideNavItems = [
- 'requestarrSmartHuntSettingsNav',
- 'requestarrRequestsNav',
- 'requestarrUsersNav',
- 'requestarrServicesNav',
- 'requestarrSettingsNav',
- ];
- hideNavItems.forEach(function(id) {
- var el = document.getElementById(id);
- if (el) el.style.display = 'none';
- });
- }
+ // 5. Hide owner-only items within Requests for ALL non-owner users
+ var hideNavItems = [
+ 'requestarrSmartHuntSettingsNav',
+ 'requestarrRequestsNav',
+ 'requestarrUsersNav',
+ 'requestarrServicesNav',
+ 'requestarrSettingsNav',
+ ];
+ hideNavItems.forEach(function(id) {
+ var el = document.getElementById(id);
+ if (el) el.style.display = 'none';
+ });
- // 5. Redirect if current section is not allowed
+ // 6. Redirect if current section is not allowed
var allowedSections = [
'requestarr', 'requestarr-discover', 'requestarr-movies',
'requestarr-tv', 'requestarr-hidden',
];
- if (role === 'admin') {
- allowedSections.push(
- 'requestarr-requests', 'requestarr-users', 'requestarr-services',
- 'requestarr-settings', 'requestarr-smarthunt-settings'
- );
- }
if (allowedSections.indexOf(this.currentSection) === -1) {
window.location.hash = '#requestarr-discover';
}
- // 6. Hide the Requests header bar (breadcrumb) — redundant for non-owner users
+ // 7. Hide the Requests header bar (breadcrumb) — redundant for non-owner users
var headerBar = document.querySelector('.requestarr-header-bar');
if (headerBar) headerBar.style.display = 'none';
-
- // 7. Mark body so CSS can adjust layout
- document.body.classList.add('non-owner-mode');
},
isAdminOnlySection: function(section) {
if (this._userRole === 'owner') return false;
if (!this._userRole) return false; // not loaded yet, don't block
- // Both admin and user roles are siloed
+ // All non-owner users are siloed to these sections only
var allowed = [
'requestarr', 'requestarr-discover', 'requestarr-movies',
'requestarr-tv', 'requestarr-hidden',
];
- if (this._userRole === 'admin') {
- allowed.push(
- 'requestarr-requests', 'requestarr-users', 'requestarr-services',
- 'requestarr-settings', 'requestarr-smarthunt-settings'
- );
- }
return allowed.indexOf(section) === -1;
},
diff --git a/frontend/static/js/dist/bundle-app.js b/frontend/static/js/dist/bundle-app.js
index d3a594d7..7a52d846 100644
--- a/frontend/static/js/dist/bundle-app.js
+++ b/frontend/static/js/dist/bundle-app.js
@@ -87,16 +87,20 @@ let huntarrUI = {
this._enableMediaHunt = generalSettings.enable_media_hunt !== false;
this._enableThirdPartyApps = generalSettings.enable_third_party_apps !== false;
// Update sidebar group visibility from database settings (nav-group-* IDs)
- var requestsGroup = document.getElementById('nav-group-requests');
- var mediaHuntGroup = document.getElementById('nav-group-media-hunt');
- var nzbHuntGroup = document.getElementById('nzb-hunt-sidebar-group');
- var appsGroup = document.getElementById('nav-group-apps');
- var appsLabel = document.getElementById('nav-group-apps-label');
- if (requestsGroup) requestsGroup.style.display = (generalSettings.enable_requestarr === false) ? 'none' : '';
- if (mediaHuntGroup) mediaHuntGroup.style.display = (generalSettings.enable_media_hunt === false) ? 'none' : '';
- if (nzbHuntGroup) nzbHuntGroup.style.display = (generalSettings.enable_media_hunt === false) ? 'none' : '';
- if (appsGroup) appsGroup.style.display = (generalSettings.enable_third_party_apps === false) ? 'none' : '';
- if (appsLabel) appsLabel.style.display = (generalSettings.enable_media_hunt === false && generalSettings.enable_third_party_apps === false) ? 'none' : '';
+ // IMPORTANT: Skip this for non-owner users — they are fully siloed
+ var isNonOwner = document.body.classList.contains('non-owner-mode');
+ if (!isNonOwner) {
+ var requestsGroup = document.getElementById('nav-group-requests');
+ var mediaHuntGroup = document.getElementById('nav-group-media-hunt');
+ var nzbHuntGroup = document.getElementById('nzb-hunt-sidebar-group');
+ var appsGroup = document.getElementById('nav-group-apps');
+ var appsLabel = document.getElementById('nav-group-apps-label');
+ if (requestsGroup) requestsGroup.style.display = (generalSettings.enable_requestarr === false) ? 'none' : '';
+ if (mediaHuntGroup) mediaHuntGroup.style.display = (generalSettings.enable_media_hunt === false) ? 'none' : '';
+ if (nzbHuntGroup) nzbHuntGroup.style.display = (generalSettings.enable_media_hunt === false) ? 'none' : '';
+ if (appsGroup) appsGroup.style.display = (generalSettings.enable_third_party_apps === false) ? 'none' : '';
+ if (appsLabel) appsLabel.style.display = (generalSettings.enable_media_hunt === false && generalSettings.enable_third_party_apps === false) ? 'none' : '';
+ }
if (typeof window.applyFeatureFlags === 'function') window.applyFeatureFlags();
// Initialize originalSettings early
@@ -278,15 +282,8 @@ let huntarrUI = {
this._pendingBadgeInterval = setInterval(() => this._updatePendingRequestBadge(), 60000);
}
} else {
- // Admin and User roles: siloed to Requests only
+ // Non-owner users: siloed to Requests only
this._applyNonOwnerRestrictions();
- // Admins get the pending badge too
- if (this._userRole === 'admin') {
- this._updatePendingRequestBadge();
- if (!this._pendingBadgeInterval) {
- this._pendingBadgeInterval = setInterval(() => this._updatePendingRequestBadge(), 60000);
- }
- }
}
})
.catch(e => {
@@ -312,96 +309,74 @@ let huntarrUI = {
},
/**
- * Non-owner users (admin + user) are siloed to Requests only.
- * - Hide Home, Core label, System, Apps label, Media Hunt, NZB Hunt, 3rd Party Apps, Sponsors
+ * Non-owner users are siloed to Requests only.
+ * - Hide Home, Core label, System, Apps label, Media Hunt, NZB Hunt, 3rd Party Apps
* - Hide the Requests group header (no accordion) — show sub-items as flat top-level nav
- * - For 'user' role: also hide admin-only items within Requests (Users, Services, Requests mgmt, Settings, Smart Hunt)
+ * - Hide owner-only items within Requests (Users, Services, Requests mgmt, Settings, Smart Hunt)
*/
_applyNonOwnerRestrictions: function() {
- var role = this._userRole;
+ // 1. Mark body FIRST so CSS !important rules take effect immediately
+ // This prevents any async settings fetch from re-showing hidden elements
+ document.body.classList.add('non-owner-mode');
- // 1. Hide everything outside Requests
- var hideElements = [
- 'homeNav', // Home button
- 'nav-group-core-label', // "Core" label
- 'nav-group-system', // System group
- 'nav-group-apps-label', // "Apps" label
- 'nav-group-media-hunt', // Media Hunt
- 'nzb-hunt-sidebar-group', // NZB Hunt
- 'nav-group-apps', // 3rd Party Apps
- 'main-sidebar-partner-projects-group', // Sponsors
- ];
- hideElements.forEach(function(id) {
- var el = document.getElementById(id);
- if (el) el.style.display = 'none';
- });
-
- // 2. Hide the Requests group header (accordion toggle) — make sub-items flat
+ // 2. Inject "Request System" label above the Requests group items
var requestsGroup = document.getElementById('nav-group-requests');
+ if (requestsGroup && !document.getElementById('non-owner-label')) {
+ var label = document.createElement('div');
+ label.id = 'non-owner-label';
+ label.className = 'nav-group';
+ label.innerHTML = 'Request System
';
+ requestsGroup.parentNode.insertBefore(label, requestsGroup);
+ }
+
+ // 3. Hide the Requests group header (accordion toggle) — make sub-items flat
if (requestsGroup) {
var header = requestsGroup.querySelector('.nav-group-header');
if (header) header.style.display = 'none';
}
- // 3. Force-expand the Requests group body and promote items to top-level styling
+ // 4. Force-expand the Requests group body and promote items to top-level styling
var requestsBody = document.getElementById('sidebar-group-requests');
if (requestsBody) {
requestsBody.classList.remove('collapsed');
requestsBody.classList.add('non-owner-flat');
}
- // 4. For 'user' role, hide admin-only items within Requests
- if (role === 'user') {
- var hideNavItems = [
- 'requestarrSmartHuntSettingsNav',
- 'requestarrRequestsNav',
- 'requestarrUsersNav',
- 'requestarrServicesNav',
- 'requestarrSettingsNav',
- ];
- hideNavItems.forEach(function(id) {
- var el = document.getElementById(id);
- if (el) el.style.display = 'none';
- });
- }
+ // 5. Hide owner-only items within Requests for ALL non-owner users
+ var hideNavItems = [
+ 'requestarrSmartHuntSettingsNav',
+ 'requestarrRequestsNav',
+ 'requestarrUsersNav',
+ 'requestarrServicesNav',
+ 'requestarrSettingsNav',
+ ];
+ hideNavItems.forEach(function(id) {
+ var el = document.getElementById(id);
+ if (el) el.style.display = 'none';
+ });
- // 5. Redirect if current section is not allowed
+ // 6. Redirect if current section is not allowed
var allowedSections = [
'requestarr', 'requestarr-discover', 'requestarr-movies',
'requestarr-tv', 'requestarr-hidden',
];
- if (role === 'admin') {
- allowedSections.push(
- 'requestarr-requests', 'requestarr-users', 'requestarr-services',
- 'requestarr-settings', 'requestarr-smarthunt-settings'
- );
- }
if (allowedSections.indexOf(this.currentSection) === -1) {
window.location.hash = '#requestarr-discover';
}
- // 6. Hide the Requests header bar (breadcrumb) — redundant for non-owner users
+ // 7. Hide the Requests header bar (breadcrumb) — redundant for non-owner users
var headerBar = document.querySelector('.requestarr-header-bar');
if (headerBar) headerBar.style.display = 'none';
-
- // 7. Mark body so CSS can adjust layout
- document.body.classList.add('non-owner-mode');
},
isAdminOnlySection: function(section) {
if (this._userRole === 'owner') return false;
if (!this._userRole) return false; // not loaded yet, don't block
- // Both admin and user roles are siloed
+ // All non-owner users are siloed to these sections only
var allowed = [
'requestarr', 'requestarr-discover', 'requestarr-movies',
'requestarr-tv', 'requestarr-hidden',
];
- if (this._userRole === 'admin') {
- allowed.push(
- 'requestarr-requests', 'requestarr-users', 'requestarr-services',
- 'requestarr-settings', 'requestarr-smarthunt-settings'
- );
- }
return allowed.indexOf(section) === -1;
},
diff --git a/frontend/static/js/dist/requestarr-bundle.js b/frontend/static/js/dist/requestarr-bundle.js
index c712e802..d4958d95 100644
--- a/frontend/static/js/dist/requestarr-bundle.js
+++ b/frontend/static/js/dist/requestarr-bundle.js
@@ -1898,14 +1898,20 @@ class RequestarrSettings {
const posterUrl = item.poster_path || './static/images/blackout.jpg';
const typeBadgeLabel = item.media_type === 'tv' ? 'TV' : 'Movie';
+
+ // Show scope badge for non-owner users
+ const isNonOwner = window._huntarrUserRole && window._huntarrUserRole !== 'owner';
+ const isGlobal = item.is_global === true;
+ const scopeBadge = isNonOwner ? (isGlobal
+ ? 'Global'
+ : 'Personal') : '';
card.innerHTML = `
-
+ ${!isGlobal || !isNonOwner ? '
' : ''}
${typeBadgeLabel}
+ ${scopeBadge}
`;
@@ -7199,7 +7205,6 @@ window.RequestarrUsers = {
diff --git a/frontend/static/js/modules/features/requestarr/requestarr-settings.js b/frontend/static/js/modules/features/requestarr/requestarr-settings.js
index a88f343d..85ed9296 100644
--- a/frontend/static/js/modules/features/requestarr/requestarr-settings.js
+++ b/frontend/static/js/modules/features/requestarr/requestarr-settings.js
@@ -415,14 +415,20 @@ export class RequestarrSettings {
const posterUrl = item.poster_path || './static/images/blackout.jpg';
const typeBadgeLabel = item.media_type === 'tv' ? 'TV' : 'Movie';
+
+ // Show scope badge for non-owner users
+ const isNonOwner = window._huntarrUserRole && window._huntarrUserRole !== 'owner';
+ const isGlobal = item.is_global === true;
+ const scopeBadge = isNonOwner ? (isGlobal
+ ? 'Global'
+ : 'Personal') : '';
card.innerHTML = `
-
+ ${!isGlobal || !isNonOwner ? '
' : ''}
${typeBadgeLabel}
+ ${scopeBadge}
`;
diff --git a/frontend/static/js/modules/features/requestarr/requestarr-users.js b/frontend/static/js/modules/features/requestarr/requestarr-users.js
index 5d4a59d7..aa5d8c55 100644
--- a/frontend/static/js/modules/features/requestarr/requestarr-users.js
+++ b/frontend/static/js/modules/features/requestarr/requestarr-users.js
@@ -147,7 +147,6 @@ window.RequestarrUsers = {
diff --git a/frontend/templates/components/requestarr_section.html b/frontend/templates/components/requestarr_section.html
index e8c2762b..b3af6c1a 100644
--- a/frontend/templates/components/requestarr_section.html
+++ b/frontend/templates/components/requestarr_section.html
@@ -4,7 +4,7 @@
-
+
diff --git a/frontend/templates/components/scripts.html b/frontend/templates/components/scripts.html
index 1ce23023..3c5ad639 100644
--- a/frontend/templates/components/scripts.html
+++ b/frontend/templates/components/scripts.html
@@ -1,9 +1,9 @@
-
+
-
+
diff --git a/src/primary/apps/requestarr_routes.py b/src/primary/apps/requestarr_routes.py
index 0a095fb9..4cbefe1c 100644
--- a/src/primary/apps/requestarr_routes.py
+++ b/src/primary/apps/requestarr_routes.py
@@ -83,9 +83,45 @@ def search_media_stream():
@requestarr_bp.route('/instances', methods=['GET'])
def get_enabled_instances():
- """Get enabled Sonarr and Radarr instances"""
+ """Get enabled Sonarr and Radarr instances.
+ For non-owner users, only return instances configured in requestarr_services.
+ """
try:
instances = requestarr_api.get_enabled_instances()
+
+ # Check if the current user is non-owner — filter to services only
+ try:
+ from src.primary.auth import get_username_from_session, SESSION_COOKIE_NAME
+ from src.primary.utils.database import get_database
+ session_token = request.cookies.get(SESSION_COOKIE_NAME)
+ username = get_username_from_session(session_token)
+ if not username:
+ from src.primary.settings_manager import load_settings
+ settings = load_settings("general")
+ if settings.get("local_access_bypass") or settings.get("proxy_auth_bypass"):
+ db = get_database()
+ main_user = db.get_first_user()
+ if main_user:
+ username = main_user.get('username')
+ if username:
+ db = get_database()
+ req_user = db.get_requestarr_user_by_username(username)
+ role = (req_user or {}).get('role', 'owner')
+ if role != 'owner':
+ # Non-owner: filter instances to only those in requestarr_services
+ services = db.get_requestarr_services()
+ # Build sets of allowed (app_type, instance_name) pairs
+ allowed = set()
+ for svc in services:
+ allowed.add((svc.get('app_type', ''), svc.get('instance_name', '')))
+ for key in list(instances.keys()):
+ instances[key] = [
+ inst for inst in instances[key]
+ if (key, inst.get('name', '')) in allowed
+ ]
+ except Exception as e:
+ logger.debug(f"Could not filter instances by role: {e}")
+
return jsonify(instances)
except Exception as e:
logger.error(f"Error getting instances: {e}")
@@ -827,6 +863,38 @@ def get_genres(media_type):
return jsonify({'error': 'Failed to get genres'}), 500
# Hidden Media Management
+
+def _get_hidden_media_user_id():
+ """Get the current user's requestarr user_id for hidden media scoping.
+ Returns None for owner (global scope), user_id for non-owner (personal scope).
+ """
+ try:
+ from src.primary.auth import get_username_from_session, SESSION_COOKIE_NAME
+ from src.primary.utils.database import get_database
+ session_token = request.cookies.get(SESSION_COOKIE_NAME)
+ username = get_username_from_session(session_token)
+ if not username:
+ from src.primary.settings_manager import load_settings
+ settings = load_settings("general")
+ if settings.get("local_access_bypass") or settings.get("proxy_auth_bypass"):
+ db = get_database()
+ main_user = db.get_first_user()
+ if main_user:
+ username = main_user.get('username')
+ if username:
+ db = get_database()
+ req_user = db.get_requestarr_user_by_username(username)
+ if req_user:
+ role = req_user.get('role', 'user')
+ if role == 'owner':
+ return None # Owner = global scope
+ return req_user.get('id') # Non-owner = personal scope
+ # Fallback: main user without requestarr record = owner
+ return None
+ except Exception:
+ pass
+ return None
+
@requestarr_bp.route('/hidden-media', methods=['POST'])
def add_hidden_media():
"""Add media to hidden list"""
@@ -842,7 +910,8 @@ def add_hidden_media():
if not all([tmdb_id, media_type, title, app_type, instance_name]):
return jsonify({'error': 'Missing required fields: tmdb_id, media_type, title, app_type, instance_name'}), 400
- success = requestarr_api.db.add_hidden_media(tmdb_id, media_type, title, app_type, instance_name, poster_path)
+ user_id = _get_hidden_media_user_id()
+ success = requestarr_api.db.add_hidden_media(tmdb_id, media_type, title, app_type, instance_name, poster_path, user_id=user_id)
if success:
return jsonify({'success': True, 'message': 'Media hidden successfully'})
@@ -855,10 +924,14 @@ def add_hidden_media():
@requestarr_bp.route('/hidden-media/
///', methods=['DELETE'])
def remove_hidden_media(tmdb_id, media_type, app_type, instance_name):
- """Remove media from hidden list (unhide) for specific instance"""
+ """Remove media from hidden list (unhide) for specific instance.
+ Non-owner users can only unhide their own personal items.
+ Owner can unhide global items.
+ """
try:
logger.info(f"DELETE /hidden-media called: tmdb_id={tmdb_id}, media_type={media_type}, app_type={app_type}, instance_name={instance_name}")
- success = requestarr_api.db.remove_hidden_media(tmdb_id, media_type, app_type, instance_name)
+ user_id = _get_hidden_media_user_id()
+ success = requestarr_api.db.remove_hidden_media(tmdb_id, media_type, app_type, instance_name, user_id=user_id)
if success:
logger.info(f"Successfully unhidden media: {tmdb_id}")
@@ -873,14 +946,18 @@ def remove_hidden_media(tmdb_id, media_type, app_type, instance_name):
@requestarr_bp.route('/hidden-media', methods=['GET'])
def get_hidden_media():
- """Get list of hidden media with pagination and optional filters"""
+ """Get list of hidden media with pagination and optional filters.
+ Non-owner users see global + their personal items.
+ Owner sees global items only.
+ """
try:
page, page_size = _safe_pagination()
media_type = request.args.get('media_type') # Optional filter
app_type = request.args.get('app_type') # Optional filter
instance_name = request.args.get('instance_name') # Optional filter
- result = requestarr_api.db.get_hidden_media(page, page_size, media_type, app_type, instance_name)
+ user_id = _get_hidden_media_user_id()
+ result = requestarr_api.db.get_hidden_media(page, page_size, media_type, app_type, instance_name, user_id=user_id)
return jsonify(result)
except Exception as e:
@@ -916,8 +993,39 @@ def has_any_clients():
@requestarr_bp.route('/instances/', methods=['GET'])
def get_instances(app_type):
- """Get list of configured instances for an app type (radarr/sonarr/movie_hunt)"""
+ """Get list of configured instances for an app type (radarr/sonarr/movie_hunt).
+ For non-owner users, only return instances configured in requestarr_services.
+ """
try:
+ # Helper to filter instances for non-owner users
+ def _filter_for_non_owner(app_type_key, instances_list):
+ try:
+ from src.primary.auth import get_username_from_session, SESSION_COOKIE_NAME as _SC
+ from src.primary.utils.database import get_database as _gdb
+ session_token = request.cookies.get(_SC)
+ username = get_username_from_session(session_token)
+ if not username:
+ from src.primary.settings_manager import load_settings
+ settings = load_settings("general")
+ if settings.get("local_access_bypass") or settings.get("proxy_auth_bypass"):
+ _db = _gdb()
+ main_user = _db.get_first_user()
+ if main_user:
+ username = main_user.get('username')
+ if username:
+ _db = _gdb()
+ req_user = _db.get_requestarr_user_by_username(username)
+ role = (req_user or {}).get('role', 'owner')
+ if role != 'owner':
+ services = _db.get_requestarr_services()
+ allowed = set()
+ for svc in services:
+ allowed.add((svc.get('app_type', ''), svc.get('instance_name', '')))
+ return [inst for inst in instances_list if (app_type_key, inst.get('name', '')) in allowed]
+ except Exception:
+ pass
+ return instances_list
+
# Movie Hunt instances come from the dedicated database table
if app_type == 'movie_hunt':
from src.primary.utils.database import get_database
@@ -938,6 +1046,7 @@ def get_instances(app_type):
'id': inst.get('id'),
'url': 'internal' # Movie Hunt is internal, no external URL
})
+ instances = _filter_for_non_owner('movie_hunt', instances)
return jsonify({'instances': instances, 'app_type': 'movie_hunt'})
# TV Hunt instances come from the dedicated database table
@@ -960,6 +1069,7 @@ def get_instances(app_type):
'id': inst.get('id'),
'url': 'internal' # TV Hunt is internal, no external URL
})
+ instances = _filter_for_non_owner('tv_hunt', instances)
return jsonify({'instances': instances, 'app_type': 'tv_hunt'})
from src.primary.settings_manager import get_setting
@@ -991,6 +1101,7 @@ def get_instances(app_type):
'url': instance.get('api_url', '') or instance.get('url', '')
})
+ instances = _filter_for_non_owner(app_type, instances)
return jsonify({'instances': instances, 'app_type': app_type})
except Exception as e:
diff --git a/src/primary/routes/requestarr_requests_routes.py b/src/primary/routes/requestarr_requests_routes.py
index 65b06c47..147a639a 100644
--- a/src/primary/routes/requestarr_requests_routes.py
+++ b/src/primary/routes/requestarr_requests_routes.py
@@ -41,13 +41,13 @@ def _get_current_user():
return None
-def _require_admin():
+def _require_owner():
"""Returns (user_dict, error_response)."""
user = _get_current_user()
if not user:
return None, (jsonify({'error': 'Not authenticated'}), 401)
role = user.get('role', 'user')
- if role not in ('owner', 'admin'):
+ if role != 'owner':
return None, (jsonify({'error': 'Insufficient permissions'}), 403)
return user, None
@@ -125,7 +125,7 @@ def list_requests():
db = get_database()
role = user.get('role', 'user')
- can_view_all = role in ('owner', 'admin') or _has_permission(user, 'view_requests')
+ can_view_all = role == 'owner' or _has_permission(user, 'view_requests')
user_id_filter = None if can_view_all else user.get('id')
requests_list = db.get_requestarr_requests(
@@ -203,7 +203,7 @@ def create_request():
@requestarr_requests_bp.route('//approve', methods=['POST'])
def approve_request(request_id):
"""Approve a pending request (admin only)."""
- current_user, err = _require_admin()
+ current_user, err = _require_owner()
if err:
return err
db = get_database()
@@ -228,7 +228,7 @@ def approve_request(request_id):
@requestarr_requests_bp.route('//deny', methods=['POST'])
def deny_request(request_id):
"""Deny a pending request (admin only)."""
- current_user, err = _require_admin()
+ current_user, err = _require_owner()
if err:
return err
db = get_database()
@@ -264,10 +264,10 @@ def delete_request(request_id):
return jsonify({'error': 'Request not found'}), 404
role = user.get('role', 'user')
- is_admin = role in ('owner', 'admin')
+ is_owner = role == 'owner'
is_own = req.get('user_id') == user.get('id')
- if not is_admin and not (is_own and req.get('status') == 'pending'):
+ if not is_owner and not (is_own and req.get('status') == 'pending'):
return jsonify({'error': 'Cannot delete this request'}), 403
if db.delete_requestarr_request(request_id):
@@ -290,7 +290,7 @@ def pending_count():
if not user:
return jsonify({'count': 0})
role = user.get('role', 'user')
- can_view = role in ('owner', 'admin') or _has_permission(user, 'manage_requests')
+ can_view = role == 'owner' or _has_permission(user, 'manage_requests')
if not can_view:
return jsonify({'count': 0})
db = get_database()
diff --git a/src/primary/routes/requestarr_services_routes.py b/src/primary/routes/requestarr_services_routes.py
index 895967da..b6e8031c 100644
--- a/src/primary/routes/requestarr_services_routes.py
+++ b/src/primary/routes/requestarr_services_routes.py
@@ -1,7 +1,7 @@
"""
Requestarr Services Routes
Manages which instances (Radarr, Sonarr, Movie Hunt, TV Hunt) are available for requests.
-Admin-only endpoints.
+Owner-only endpoints.
"""
from flask import Blueprint, request, jsonify
@@ -14,8 +14,8 @@ logger = logging.getLogger(__name__)
requestarr_services_bp = Blueprint('requestarr_services', __name__, url_prefix='/api/requestarr/services')
-def _require_admin():
- """Check that the current user is owner or admin."""
+def _require_owner():
+ """Check that the current user is owner."""
session_token = request.cookies.get(SESSION_COOKIE_NAME)
username = get_username_from_session(session_token)
if not username:
@@ -37,7 +37,7 @@ def _require_admin():
# Check requestarr_users role, fallback to owner for main admin
req_user = db.get_requestarr_user_by_username(username)
role = (req_user or {}).get('role', 'owner')
- if role not in ('owner', 'admin'):
+ if role != 'owner':
return None, (jsonify({'error': 'Insufficient permissions'}), 403)
return user, None
@@ -45,7 +45,7 @@ def _require_admin():
@requestarr_services_bp.route('', methods=['GET'])
def get_services():
"""Get all configured requestarr services."""
- _, err = _require_admin()
+ _, err = _require_owner()
if err:
return err
try:
@@ -63,7 +63,7 @@ def get_available_instances():
"""Get all available instances that can be added as services.
Returns Radarr + Movie Hunt instances for movies, Sonarr + TV Hunt for TV.
"""
- _, err = _require_admin()
+ _, err = _require_owner()
if err:
return err
try:
@@ -127,7 +127,7 @@ def get_available_instances():
@requestarr_services_bp.route('', methods=['POST'])
def add_service():
"""Add an instance as a requestarr service."""
- _, err = _require_admin()
+ _, err = _require_owner()
if err:
return err
try:
@@ -158,7 +158,7 @@ def add_service():
@requestarr_services_bp.route('/', methods=['PUT'])
def update_service(service_id):
"""Update a service (toggle default, 4K, enabled)."""
- _, err = _require_admin()
+ _, err = _require_owner()
if err:
return err
try:
@@ -176,7 +176,7 @@ def update_service(service_id):
@requestarr_services_bp.route('/', methods=['DELETE'])
def remove_service(service_id):
"""Remove a service."""
- _, err = _require_admin()
+ _, err = _require_owner()
if err:
return err
try:
diff --git a/src/primary/routes/requestarr_users_routes.py b/src/primary/routes/requestarr_users_routes.py
index 3f8851b4..c8391866 100644
--- a/src/primary/routes/requestarr_users_routes.py
+++ b/src/primary/routes/requestarr_users_routes.py
@@ -1,7 +1,7 @@
"""
Requestarr User Management Routes
Handles CRUD for local users, role management, and Plex user import.
-Admin-only endpoints (role == 'owner' or 'admin').
+Owner-only endpoints (role == 'owner').
"""
from flask import Blueprint, request, jsonify
@@ -52,13 +52,13 @@ def _get_current_user():
return None
-def _require_admin():
+def _require_owner():
"""Returns (user_dict, error_response). If error_response is not None, return it."""
user = _get_current_user()
if not user:
return None, (jsonify({'error': 'Not authenticated'}), 401)
role = user.get('role', 'user')
- if role not in ('owner', 'admin'):
+ if role != 'owner':
return None, (jsonify({'error': 'Insufficient permissions'}), 403)
return user, None
@@ -105,17 +105,6 @@ DEFAULT_PERMISSIONS = {
'view_requests': True,
'hide_media_global': True,
},
- 'admin': {
- 'request_movies': True,
- 'request_tv': True,
- 'auto_approve': True,
- 'auto_approve_movies': True,
- 'auto_approve_tv': True,
- 'manage_requests': True,
- 'manage_users': True,
- 'view_requests': True,
- 'hide_media_global': False,
- },
'user': {
'request_movies': True,
'request_tv': True,
@@ -134,8 +123,8 @@ DEFAULT_PERMISSIONS = {
@requestarr_users_bp.route('', methods=['GET'])
def list_users():
- """List all users (admin only)."""
- _, err = _require_admin()
+ """List all users (owner only)."""
+ _, err = _require_owner()
if err:
return err
try:
@@ -149,8 +138,8 @@ def list_users():
@requestarr_users_bp.route('', methods=['POST'])
def create_user():
- """Create a local user (admin only)."""
- current_user, err = _require_admin()
+ """Create a local user (owner only)."""
+ current_user, err = _require_owner()
if err:
return err
try:
@@ -164,8 +153,8 @@ def create_user():
return jsonify({'error': 'Username must be at least 3 characters'}), 400
if not password or len(password) < 8:
return jsonify({'error': 'Password must be at least 8 characters'}), 400
- if role not in ('admin', 'user'):
- return jsonify({'error': 'Invalid role. Must be admin or user'}), 400
+ if role not in ('user',):
+ return jsonify({'error': 'Invalid role. Must be user'}), 400
# Generate default permissions for the role
import json
@@ -194,8 +183,8 @@ def create_user():
@requestarr_users_bp.route('/', methods=['PUT'])
def update_user(user_id):
- """Update a user (admin only)."""
- current_user, err = _require_admin()
+ """Update a user (owner only)."""
+ current_user, err = _require_owner()
if err:
return err
try:
@@ -214,7 +203,7 @@ def update_user(user_id):
updates['username'] = data['username'].strip()
if 'email' in data:
updates['email'] = (data['email'] or '').strip()
- if 'role' in data and data['role'] in ('admin', 'user'):
+ if 'role' in data and data['role'] in ('user',):
if target.get('role') != 'owner':
updates['role'] = data['role']
if 'password' in data and data['password']:
@@ -239,8 +228,8 @@ def update_user(user_id):
@requestarr_users_bp.route('/', methods=['DELETE'])
def delete_user(user_id):
- """Delete a user (admin only). Cannot delete owner."""
- current_user, err = _require_admin()
+ """Delete a user (owner only). Cannot delete owner."""
+ current_user, err = _require_owner()
if err:
return err
try:
@@ -302,8 +291,8 @@ def generate_password():
@requestarr_users_bp.route('/plex/friends', methods=['GET'])
def get_plex_friends():
- """Get Plex friends list for import (admin only). Requires the owner to have linked Plex."""
- _, err = _require_admin()
+ """Get Plex friends list for import (owner only). Requires the owner to have linked Plex."""
+ _, err = _require_owner()
if err:
return err
try:
@@ -343,8 +332,8 @@ def get_plex_friends():
@requestarr_users_bp.route('/plex/import', methods=['POST'])
def import_plex_users():
- """Import selected Plex friends as local users (admin only)."""
- current_user, err = _require_admin()
+ """Import selected Plex friends as local users (owner only)."""
+ current_user, err = _require_owner()
if err:
return err
try:
diff --git a/src/primary/utils/database.py b/src/primary/utils/database.py
index 0d79e264..93df7fce 100644
--- a/src/primary/utils/database.py
+++ b/src/primary/utils/database.py
@@ -895,6 +895,13 @@ class HuntarrDatabase:
except sqlite3.OperationalError:
pass # Column already exists
+ # Add user_id column for per-user hidden media (NULL = global/owner, user_id = personal)
+ try:
+ conn.execute('ALTER TABLE requestarr_hidden_media ADD COLUMN user_id INTEGER')
+ logger.info("Added user_id column to requestarr_hidden_media table")
+ except sqlite3.OperationalError:
+ pass # Column already exists
+
# Add temp_2fa_secret column if it doesn't exist (for existing databases)
try:
conn.execute('ALTER TABLE users ADD COLUMN temp_2fa_secret TEXT')
@@ -3612,59 +3619,98 @@ class HuntarrDatabase:
# HIDDEN MEDIA MANAGEMENT
# ========================================
- def add_hidden_media(self, tmdb_id: int, media_type: str, title: str, app_type: str, instance_name: str, poster_path: str = None) -> bool:
- """Add media to hidden list for specific instance"""
+ def add_hidden_media(self, tmdb_id: int, media_type: str, title: str, app_type: str, instance_name: str, poster_path: str = None, user_id: int = None) -> bool:
+ """Add media to hidden list for specific instance.
+ user_id=None means global (owner), user_id=N means personal (that user only).
+ """
try:
with self.get_connection() as conn:
now = int(time.time())
readable_time = datetime.fromtimestamp(now).strftime('%Y-%m-%d %H:%M:%S')
+ # Check if already exists for this user scope
+ if user_id is not None:
+ existing = conn.execute(
+ 'SELECT id FROM requestarr_hidden_media WHERE tmdb_id = ? AND media_type = ? AND app_type = ? AND instance_name = ? AND user_id = ?',
+ (tmdb_id, media_type, app_type, instance_name, user_id)
+ ).fetchone()
+ else:
+ existing = conn.execute(
+ 'SELECT id FROM requestarr_hidden_media WHERE tmdb_id = ? AND media_type = ? AND app_type = ? AND instance_name = ? AND user_id IS NULL',
+ (tmdb_id, media_type, app_type, instance_name)
+ ).fetchone()
+
+ if existing:
+ return True # Already hidden
+
conn.execute('''
- INSERT OR REPLACE INTO requestarr_hidden_media
- (tmdb_id, media_type, title, poster_path, app_type, instance_name, hidden_at, hidden_at_readable)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- ''', (tmdb_id, media_type, title, poster_path, app_type, instance_name, now, readable_time))
+ INSERT INTO requestarr_hidden_media
+ (tmdb_id, media_type, title, poster_path, app_type, instance_name, hidden_at, hidden_at_readable, user_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ''', (tmdb_id, media_type, title, poster_path, app_type, instance_name, now, readable_time, user_id))
conn.commit()
- logger.info(f"Added hidden media: {title} (TMDB ID: {tmdb_id}, Type: {media_type}, Instance: {app_type}/{instance_name})")
+ scope = f"user_id={user_id}" if user_id else "global"
+ logger.info(f"Added hidden media: {title} (TMDB ID: {tmdb_id}, Type: {media_type}, Instance: {app_type}/{instance_name}, Scope: {scope})")
return True
except Exception as e:
logger.error(f"Error adding hidden media: {e}")
return False
- def remove_hidden_media(self, tmdb_id: int, media_type: str, app_type: str, instance_name: str) -> bool:
- """Remove media from hidden list for specific instance"""
+ def remove_hidden_media(self, tmdb_id: int, media_type: str, app_type: str, instance_name: str, user_id: int = None) -> bool:
+ """Remove media from hidden list for specific instance and user scope."""
try:
- logger.debug(f"remove_hidden_media called with: tmdb_id={tmdb_id}, media_type={media_type}, app_type={app_type}, instance_name={instance_name}")
+ logger.debug(f"remove_hidden_media called with: tmdb_id={tmdb_id}, media_type={media_type}, app_type={app_type}, instance_name={instance_name}, user_id={user_id}")
with self.get_connection() as conn:
- cursor = conn.execute('''
- DELETE FROM requestarr_hidden_media
- WHERE tmdb_id = ? AND media_type = ? AND app_type = ? AND instance_name = ?
- ''', (tmdb_id, media_type, app_type, instance_name))
+ if user_id is not None:
+ cursor = conn.execute('''
+ DELETE FROM requestarr_hidden_media
+ WHERE tmdb_id = ? AND media_type = ? AND app_type = ? AND instance_name = ? AND user_id = ?
+ ''', (tmdb_id, media_type, app_type, instance_name, user_id))
+ else:
+ cursor = conn.execute('''
+ DELETE FROM requestarr_hidden_media
+ WHERE tmdb_id = ? AND media_type = ? AND app_type = ? AND instance_name = ? AND user_id IS NULL
+ ''', (tmdb_id, media_type, app_type, instance_name))
rows_deleted = cursor.rowcount
conn.commit()
- logger.info(f"Removed hidden media: TMDB ID {tmdb_id}, Type: {media_type}, Instance: {app_type}/{instance_name}, Rows deleted: {rows_deleted}")
+ logger.info(f"Removed hidden media: TMDB ID {tmdb_id}, Type: {media_type}, Instance: {app_type}/{instance_name}, user_id={user_id}, Rows deleted: {rows_deleted}")
return True
except Exception as e:
logger.error(f"Error removing hidden media: {e}")
return False
- def is_media_hidden(self, tmdb_id: int, media_type: str, app_type: str, instance_name: str) -> bool:
- """Check if media is hidden for specific instance"""
+ def is_media_hidden(self, tmdb_id: int, media_type: str, app_type: str, instance_name: str, user_id: int = None) -> bool:
+ """Check if media is hidden for specific instance.
+ Checks both global (user_id IS NULL) and personal (user_id = N) entries.
+ """
try:
with self.get_connection() as conn:
- cursor = conn.execute('''
- SELECT 1 FROM requestarr_hidden_media
- WHERE tmdb_id = ? AND media_type = ? AND app_type = ? AND instance_name = ?
- ''', (tmdb_id, media_type, app_type, instance_name))
+ if user_id is not None:
+ # Non-owner: check global OR personal
+ cursor = conn.execute('''
+ SELECT 1 FROM requestarr_hidden_media
+ WHERE tmdb_id = ? AND media_type = ? AND app_type = ? AND instance_name = ?
+ AND (user_id IS NULL OR user_id = ?)
+ ''', (tmdb_id, media_type, app_type, instance_name, user_id))
+ else:
+ # Owner: check global only
+ cursor = conn.execute('''
+ SELECT 1 FROM requestarr_hidden_media
+ WHERE tmdb_id = ? AND media_type = ? AND app_type = ? AND instance_name = ?
+ AND user_id IS NULL
+ ''', (tmdb_id, media_type, app_type, instance_name))
return cursor.fetchone() is not None
except Exception as e:
logger.error(f"Error checking if media is hidden: {e}")
return False
- def get_hidden_media(self, page: int = 1, page_size: int = 20, media_type: str = None, app_type: str = None, instance_name: str = None) -> Dict[str, Any]:
- """Get paginated list of hidden media, optionally filtered by media_type, app_type, and instance"""
+ def get_hidden_media(self, page: int = 1, page_size: int = 20, media_type: str = None, app_type: str = None, instance_name: str = None, user_id: int = None) -> Dict[str, Any]:
+ """Get paginated list of hidden media, optionally filtered by media_type, app_type, instance, and user.
+ For non-owner users (user_id provided): returns global (user_id IS NULL) + personal (user_id = N).
+ For owner (user_id=None): returns global items only (user_id IS NULL).
+ """
try:
offset = (page - 1) * page_size
@@ -3685,6 +3731,15 @@ class HuntarrDatabase:
where_clauses.append("instance_name = ?")
params.append(instance_name)
+ # User scope filter
+ if user_id is not None:
+ # Non-owner: see global + their own personal items
+ where_clauses.append("(user_id IS NULL OR user_id = ?)")
+ params.append(user_id)
+ else:
+ # Owner: see only global items
+ where_clauses.append("user_id IS NULL")
+
where_clause = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
# Get total count
@@ -3694,7 +3749,7 @@ class HuntarrDatabase:
# Get paginated results
query = f'''
- SELECT id, tmdb_id, media_type, title, poster_path, app_type, instance_name, hidden_at, hidden_at_readable
+ SELECT id, tmdb_id, media_type, title, poster_path, app_type, instance_name, hidden_at, hidden_at_readable, user_id
FROM requestarr_hidden_media
{where_clause}
ORDER BY hidden_at DESC
@@ -3714,7 +3769,9 @@ class HuntarrDatabase:
'app_type': row[5],
'instance_name': row[6],
'hidden_at': row[7],
- 'hidden_at_readable': row[8]
+ 'hidden_at_readable': row[8],
+ 'user_id': row[9],
+ 'is_global': row[9] is None,
})
return {