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 = ''; + 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 = ''; + 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 ? '' : ''} ${item.title} ${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 ? '' : ''} ${item.title} ${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 {