├── .github ├── screenshots │ ├── cache-admin.png │ ├── cast-example.png │ ├── collection-view.png │ ├── filmography-example.png │ ├── homepage-mode-iframe.png │ ├── login-page-new.png │ ├── login-page.png │ ├── main-interface.png │ ├── movie-details-example.png │ ├── poster-mode.png │ ├── pwa-interface-mobile.png │ └── test-theme.png └── workflows │ ├── add-donation.yml │ └── docker-build.yml ├── Dockerfile ├── Dockerfile.arm ├── LICENSE ├── README.md ├── movie_selector.py ├── requirements.txt ├── routes ├── overseerr_routes.py ├── trakt_routes.py └── user_cache_routes.py ├── sample-compose.yml ├── static ├── icons │ ├── favicon.ico │ ├── icon-192x192.png │ ├── icon-512x512.png │ └── icon.png ├── images │ └── default_poster.png ├── js │ ├── auth.js │ ├── emby-auth.js │ ├── jellyfin-auth.js │ ├── plex-auth.js │ ├── poster.js │ ├── script.js │ ├── service-worker.js │ ├── settings.js │ ├── settings_managed_users.js │ ├── settings_passkeys.js │ └── user_cache.js ├── logos │ ├── imdb_logo.svg │ ├── tmdb_logo.svg │ ├── trakt_logo.svg │ └── youtube_logo.svg ├── manifest.json └── style │ ├── auth.css │ ├── login.css │ ├── poster.css │ ├── settings.css │ └── style.css ├── utils ├── __init__.py ├── __pycache__ │ ├── __init__.cpython-311.pyc │ └── plex_service.cpython-311.pyc ├── appletv_discovery.py ├── auth │ ├── __init__.py │ ├── db.py │ ├── managed_user_routes.py │ ├── manager.py │ ├── passkey_routes.py │ └── routes.py ├── cache_manager.py ├── collection_service.py ├── default_poster_manager.py ├── emby_service.py ├── fetch_movie_links.py ├── jellyfin_service.py ├── jellyseerr_service.py ├── ombi_service.py ├── overseerr_service.py ├── playback_monitor.py ├── plex_service.py ├── poster_view.py ├── services_loader.py ├── settings │ ├── __init__.py │ ├── config.py │ ├── manager.py │ ├── routes.py │ └── validators.py ├── tmdb_service.py ├── trakt_service.py ├── tv │ ├── __init__.py │ ├── base │ │ ├── __init__.py │ │ ├── tv_base.py │ │ ├── tv_discovery.py │ │ └── tv_factory.py │ ├── discovery │ │ ├── __init__.py │ │ ├── android_discovery.py │ │ ├── tizen_discovery.py │ │ └── webos_discovery.py │ └── implementations │ │ ├── __init__.py │ │ ├── android_tv.py │ │ ├── tizen_tv.py │ │ └── webos_tv.py ├── user_cache.py ├── version.py └── youtube_trailer.py └── web ├── index.html ├── login.html ├── plex_auth_success.html ├── poster.html ├── settings.html ├── setup.html └── user_cache_admin.html /.github/screenshots/cache-admin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/.github/screenshots/cache-admin.png -------------------------------------------------------------------------------- /.github/screenshots/cast-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/.github/screenshots/cast-example.png -------------------------------------------------------------------------------- /.github/screenshots/collection-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/.github/screenshots/collection-view.png -------------------------------------------------------------------------------- /.github/screenshots/filmography-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/.github/screenshots/filmography-example.png -------------------------------------------------------------------------------- /.github/screenshots/homepage-mode-iframe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/.github/screenshots/homepage-mode-iframe.png -------------------------------------------------------------------------------- /.github/screenshots/login-page-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/.github/screenshots/login-page-new.png -------------------------------------------------------------------------------- /.github/screenshots/login-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/.github/screenshots/login-page.png -------------------------------------------------------------------------------- /.github/screenshots/main-interface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/.github/screenshots/main-interface.png -------------------------------------------------------------------------------- /.github/screenshots/movie-details-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/.github/screenshots/movie-details-example.png -------------------------------------------------------------------------------- /.github/screenshots/poster-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/.github/screenshots/poster-mode.png -------------------------------------------------------------------------------- /.github/screenshots/pwa-interface-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/.github/screenshots/pwa-interface-mobile.png -------------------------------------------------------------------------------- /.github/screenshots/test-theme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/.github/screenshots/test-theme.png -------------------------------------------------------------------------------- /.github/workflows/add-donation.yml: -------------------------------------------------------------------------------- 1 | name: Add Donation Message 2 | on: 3 | issue_comment: 4 | types: [created] 5 | pull_request_review_comment: 6 | types: [created] 7 | 8 | jobs: 9 | add-donation: 10 | runs-on: ubuntu-latest 11 | if: github.actor == github.repository_owner 12 | steps: 13 | - name: Add donation message 14 | uses: actions/github-script@v7 15 | with: 16 | github-token: ${{ secrets.GITHUB_TOKEN }} 17 | script: | 18 | const donationMessage = '\n\n---\n❤️ If you find the project helpful, consider [buying me a coffee](https://ko-fi.com/sahara101/donate) or [sponsor on github](https://github.com/sponsors/sahara101).'; 19 | 20 | if (context.eventName === 'issue_comment') { 21 | await github.rest.issues.updateComment({ 22 | owner: context.repo.owner, 23 | repo: context.repo.repo, 24 | comment_id: context.payload.comment.id, 25 | body: context.payload.comment.body + donationMessage 26 | }); 27 | } else { 28 | await github.rest.pulls.updateReviewComment({ 29 | owner: context.repo.owner, 30 | repo: context.repo.repo, 31 | comment_id: context.payload.comment.id, 32 | body: context.payload.comment.body + donationMessage 33 | }); 34 | } 35 | 36 | console.log('Successfully updated comment with donation message'); 37 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build 2 | on: 3 | push: 4 | branches: [ main ] 5 | paths: 6 | - 'Dockerfile' 7 | - 'Dockerfile.arm' 8 | - 'requirements.txt' 9 | - 'movie_selector.py' 10 | - '.github/workflows/docker-build.yml' 11 | workflow_dispatch: 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up QEMU 21 | uses: docker/setup-qemu-action@v3 22 | with: 23 | platforms: arm64,arm 24 | - name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@v3 26 | - name: Install latest rust 27 | uses: actions-rs/toolchain@v1 28 | with: 29 | toolchain: stable 30 | override: true 31 | - name: Cache Docker layers 32 | uses: actions/cache@v3 33 | with: 34 | path: /tmp/.buildx-cache 35 | key: ${{ runner.os }}-buildx-${{ github.sha }} 36 | restore-keys: | 37 | ${{ runner.os }}-buildx- 38 | - name: Login to Docker Hub 39 | uses: docker/login-action@v3 40 | with: 41 | username: sahara101 42 | password: ${{ secrets.DOCKER_PASSWORD }} 43 | - name: Login to GitHub Container Registry 44 | uses: docker/login-action@v3 45 | with: 46 | registry: ghcr.io 47 | username: ${{ github.actor }} 48 | password: ${{ secrets.GITHUB_TOKEN }} 49 | - name: Build and push AMD64 50 | #if: false 51 | uses: docker/build-push-action@v5 52 | with: 53 | context: . 54 | file: Dockerfile 55 | platforms: linux/amd64 56 | push: true 57 | tags: | 58 | sahara101/movie-roulette:latest 59 | sahara101/movie-roulette:v4.1.2 60 | ghcr.io/sahara101/movie-roulette:latest 61 | ghcr.io/sahara101/movie-roulette:v4.1.2 62 | - name: Build and push ARM 63 | #if: false 64 | uses: docker/build-push-action@v5 65 | with: 66 | context: . 67 | file: Dockerfile.arm 68 | platforms: linux/arm64,linux/arm/v7 69 | push: true 70 | tags: | 71 | sahara101/movie-roulette:arm-latest 72 | sahara101/movie-roulette:arm-v4.1.2 73 | ghcr.io/sahara101/movie-roulette:arm-latest 74 | ghcr.io/sahara101/movie-roulette:arm-v4.1.2 75 | cache-from: type=local,src=/tmp/.buildx-cache 76 | cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max 77 | - name: Move cache 78 | run: | 79 | rm -rf /tmp/.buildx-cache 80 | mv /tmp/.buildx-cache-new /tmp/.buildx-cache 81 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Python runtime as a parent image 2 | FROM python:3.9-slim 3 | # Install required system packages 4 | RUN apt-get update && \ 5 | apt-get install -y --no-install-recommends \ 6 | arp-scan \ 7 | iputils-ping \ 8 | netcat-openbsd \ 9 | iproute2 \ 10 | && rm -rf /var/lib/apt/lists/* \ 11 | && apt-get clean 12 | # Set the working directory in the container 13 | WORKDIR /app 14 | # Copy the current directory contents into the container at /app 15 | COPY . /app 16 | # Install any needed packages specified in requirements.txt 17 | RUN pip install --no-cache-dir -r requirements.txt 18 | # Make port 4000 available to the world outside this container 19 | EXPOSE 4000 20 | # Volume for persistent data 21 | VOLUME /app/data 22 | # Run the application with Gunicorn 23 | CMD ["gunicorn", "-k", "eventlet", "-w", "1", "-b", "0.0.0.0:4000", "movie_selector:app"] 24 | -------------------------------------------------------------------------------- /Dockerfile.arm: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim-bullseye AS builder 2 | ENV CARGO_BUILD_JOBS=4 3 | RUN apt-get update && \ 4 | apt-get install -y --no-install-recommends \ 5 | build-essential g++ python3-dev gcc libffi-dev \ 6 | curl libssl-dev pkg-config \ 7 | portaudio19-dev libasound2-dev cmake \ 8 | coreutils libffi-dev && \ 9 | rm -rf /var/lib/apt/lists/* 10 | ENV RUSTUP_HOME=/usr/local/rustup \ 11 | CARGO_HOME=/usr/local/cargo \ 12 | PATH=/usr/local/cargo/bin:$PATH \ 13 | PKG_CONFIG_PATH=/usr/lib/arm-linux-gnueabihf/pkgconfig:/usr/lib/pkgconfig 14 | RUN curl https://sh.rustup.rs -sSf | sh -s -- -y 15 | WORKDIR /build 16 | RUN pip install --upgrade pip setuptools wheel 17 | RUN timeout 3600 pip wheel --no-deps --no-cache-dir --wheel-dir /wheels \ 18 | cffi setuptools-rust cryptography==44.0.0 && \ 19 | GEVENT_CONFIGURE_KWARGS="--disable-dependency-tracking" \ 20 | pip wheel --no-deps --no-cache-dir --wheel-dir /wheels gevent 21 | 22 | FROM python:3.9-slim-bullseye 23 | RUN apt-get update && \ 24 | apt-get install -y --no-install-recommends \ 25 | arp-scan iputils-ping netcat-openbsd iproute2 \ 26 | portaudio19-dev libasound2-dev \ 27 | g++ libffi-dev && \ 28 | rm -rf /var/lib/apt/lists/* 29 | WORKDIR /app 30 | COPY . . 31 | COPY --from=builder /wheels /wheels 32 | RUN pip install --no-cache-dir /wheels/*.whl && \ 33 | pip install --no-cache-dir -r requirements.txt && \ 34 | rm -rf /wheels 35 | EXPOSE 4000 36 | VOLUME /app/data 37 | CMD ["gunicorn", "-k", "eventlet", "-w", "1", "-b", "0.0.0.0:4000", "movie_selector:app"] 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Akasiek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | eel 2 | plexapi 3 | configparser 4 | pyatv==0.15.1 5 | Flask 6 | requests 7 | gunicorn 8 | pywebostv 9 | flask-socketio 10 | eventlet 11 | pytz 12 | python-dotenv 13 | pexpect 14 | adb-shell>=0.4.0 15 | websocket-client 16 | wakeonlan 17 | Flask-WTF>=1.0.0 18 | webauthn>=1.6.0,<3.0.0 -------------------------------------------------------------------------------- /sample-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | movie-roulette: 3 | image: ghcr.io/sahara101/movie-roulette:latest 4 | container_name: movie-roulette 5 | environment: 6 | # Core Settings 7 | FLASK_SECRET_KEY: "" # Random string of characters 8 | DISABLE_SETTINGS: "FALSE" # Lock Settings page 9 | AUTH_ENABLED: "TRUE" # Enable authentication 10 | AUTH_SESSION_LIFETIME: "86400" # Session lifetime in seconds 11 | 12 | # Passkeys 13 | AUTH_PASSKEY_ENABLED: TRUE 14 | AUTH_RELYING_PARTY_ID: yourdomain.com 15 | AUTH_RELYING_PARTY_ORIGIN: https://roulette.yourdomain.com 16 | 17 | # Media Server Configurations - Required if using service 18 | # Plex Configuration 19 | PLEX_URL: "http://plex.example.com" 20 | PLEX_TOKEN: "your-plex-token" 21 | PLEX_MOVIE_LIBRARIES: "Movies,4K Movies" # Comma-separated library names 22 | 23 | # Jellyfin Configuration 24 | JELLYFIN_URL: "http://jellyfin.example.com" 25 | JELLYFIN_API_KEY: "your-jellyfin-api-key" 26 | JELLYFIN_USER_ID: "your-jellyfin-user-id" 27 | 28 | # Emby Configuration 29 | EMBY_URL: "http://emby.example.com" 30 | EMBY_API_KEY: "your-emby-api-key" 31 | EMBY_USER_ID: "your-emby-user-id" 32 | 33 | # Optional Features 34 | LOGIN_BACKDROP_ENABLED: TRUE # Show random movie backdrops on the login page. 35 | HOMEPAGE_MODE: "FALSE" # Homepage widget mode 36 | TMDB_API_KEY: "your-tmdb-key" # Custom TMDb key (optional) 37 | USE_LINKS: "TRUE" # Show links buttons 38 | USE_FILTER: "TRUE" # Show filter button 39 | USE_WATCH_BUTTON: "TRUE" # Show Watch button 40 | USE_NEXT_BUTTON: "TRUE" # Show next button 41 | ENABLE_MOBILE_TRUNCATION: "FALSE" # Truncate descriptions on mobile 42 | ENABLE_MOVIE_LOGOS: "TRUE" # Show movie titles as logos 43 | LOAD_MOVIE_ON_START: "FALSE" # Adds a button to get a random movie 44 | 45 | # Request Service Configuration (Optional) 46 | # Service URLs and API Keys 47 | OVERSEERR_URL: "http://overseerr.example.com" 48 | OVERSEERR_API_KEY: "your-overseerr-api-key" 49 | 50 | JELLYSEERR_URL: "http://jellyseerr.example.com" 51 | JELLYSEERR_API_KEY: "your-jellyseerr-api-key" 52 | 53 | OMBI_URL: "http://ombi.example.com" 54 | OMBI_API_KEY: "your-ombi-api-key" 55 | 56 | # Request Service Preferences 57 | REQUEST_SERVICE_DEFAULT: "auto" # Default request service 58 | REQUEST_SERVICE_PLEX: "auto" # Plex override 59 | REQUEST_SERVICE_JELLYFIN: "auto" # Jellyfin override 60 | REQUEST_SERVICE_EMBY: "auto" # Emby override 61 | 62 | # Device Control Configuration (Optional) 63 | APPLE_TV_ID: "your-apple-tv-id" # Apple TV identifier 64 | 65 | # Smart TV Configuration (use letters, numbers, and underscores in NAME) 66 | TV_LIVING_ROOM_TYPE: "webos" # Options: webos, tizen, android 67 | TV_LIVING_ROOM_IP: "192.168.1.100" 68 | TV_LIVING_ROOM_MAC: "XX:XX:XX:XX:XX:XX" 69 | 70 | # Cinema Poster Configuration (Optional) 71 | TZ: "UTC" # Poster timezone 72 | DEFAULT_POSTER_TEXT: "My Cinema" # Default text 73 | POSTER_MODE: "default" # Options: default, screensaver 74 | POSTER_DISPLAY_MODE: "first_active" # Options: first_active, preferred_user 75 | SCREENSAVER_INTERVAL: "300" # Update interval in seconds 76 | 77 | # User Monitoring Configuration 78 | PLEX_POSTER_USERS: "user1,user2" # Plex users to monitor 79 | JELLYFIN_POSTER_USERS: "user1,user2" # Jellyfin users to monitor 80 | EMBY_POSTER_USERS: "user1,user2" # Emby users to monitor 81 | PREFERRED_POSTER_USER: "username" # User that should always be visible 82 | PREFERRED_POSTER_SERVICE: "plex" # Service for preferred user 83 | 84 | # Custom Trakt Configuration (Optional) 85 | TRAKT_CLIENT_ID: "your-trakt-client-id" 86 | TRAKT_CLIENT_SECRET: "your-trakt-secret" 87 | TRAKT_ACCESS_TOKEN: "your-trakt-access-token" 88 | TRAKT_REFRESH_TOKEN: "your-trakt-refresh-token" 89 | 90 | volumes: 91 | - /path/to/data:/app/data 92 | restart: unless-stopped 93 | -------------------------------------------------------------------------------- /static/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/static/icons/favicon.ico -------------------------------------------------------------------------------- /static/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/static/icons/icon-192x192.png -------------------------------------------------------------------------------- /static/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/static/icons/icon-512x512.png -------------------------------------------------------------------------------- /static/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/static/icons/icon.png -------------------------------------------------------------------------------- /static/images/default_poster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/static/images/default_poster.png -------------------------------------------------------------------------------- /static/js/emby-auth.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', () => { 2 | const embyLoginBtn = document.getElementById('emby-login-btn'); 3 | const embyAuthModal = document.getElementById('emby-auth-modal'); 4 | const embyAuthForm = document.getElementById('emby-auth-form'); 5 | const cancelEmbyAuthBtn = document.getElementById('cancel-emby-auth'); 6 | const embyUsernameInput = document.getElementById('emby-username'); 7 | const embyPasswordInput = document.getElementById('emby-password'); 8 | 9 | const getCsrfToken = () => { 10 | const tokenMeta = document.querySelector('meta[name="csrf-token"]'); 11 | return tokenMeta ? tokenMeta.getAttribute('content') : null; 12 | }; 13 | 14 | const showElementFlex = (el) => { if (el) el.style.display = 'flex'; }; 15 | const hideElement = (el) => { if (el) el.style.display = 'none'; }; 16 | 17 | const resetModalState = () => { 18 | if (embyUsernameInput) embyUsernameInput.value = ''; 19 | if (embyPasswordInput) embyPasswordInput.value = ''; 20 | clearError(embyAuthForm); 21 | }; 22 | 23 | const showModal = () => { 24 | if (embyAuthModal) { 25 | resetModalState(); 26 | showElementFlex(embyAuthModal); 27 | if (embyUsernameInput) embyUsernameInput.focus(); 28 | } 29 | }; 30 | 31 | const hideModal = () => { 32 | if (embyAuthModal) { 33 | hideElement(embyAuthModal); 34 | } 35 | }; 36 | 37 | const showError = (message, targetElement = embyAuthForm) => { 38 | if (targetElement) { 39 | clearError(targetElement); 40 | const errorDiv = document.createElement('div'); 41 | errorDiv.className = 'error-message'; 42 | errorDiv.innerHTML = ` ${message}`; 43 | targetElement.insertBefore(errorDiv, targetElement.firstChild); 44 | } 45 | }; 46 | 47 | const clearError = (targetElement = embyAuthForm) => { 48 | if (targetElement) { 49 | const existingError = targetElement.querySelector('.error-message'); 50 | if (existingError) { 51 | existingError.remove(); 52 | } 53 | } 54 | }; 55 | 56 | const setLoadingState = (isLoading) => { 57 | const submitButton = embyAuthForm?.querySelector('button[type="submit"]'); 58 | const inputs = embyAuthForm?.querySelectorAll('input'); 59 | const buttons = embyAuthForm?.querySelectorAll('button'); 60 | 61 | if (submitButton) { 62 | submitButton.disabled = isLoading; 63 | submitButton.innerHTML = isLoading 64 | ? ' Signing In...' 65 | : 'Sign In'; 66 | } 67 | inputs?.forEach(input => input.disabled = isLoading); 68 | buttons?.forEach(button => { 69 | if (!button.classList.contains('cancel-button')) { 70 | button.disabled = isLoading; 71 | } 72 | }); 73 | }; 74 | 75 | if (embyLoginBtn) { 76 | embyLoginBtn.addEventListener('click', showModal); 77 | } 78 | 79 | if (cancelEmbyAuthBtn) { 80 | cancelEmbyAuthBtn.addEventListener('click', hideModal); 81 | } 82 | 83 | if (embyAuthForm) { 84 | embyAuthForm.addEventListener('submit', async (event) => { 85 | event.preventDefault(); 86 | const username = embyUsernameInput ? embyUsernameInput.value.trim() : null; 87 | const password = embyPasswordInput ? embyPasswordInput.value : null; 88 | const csrfToken = getCsrfToken(); 89 | 90 | if (!username || !password) { 91 | showError('Username and password are required.'); 92 | return; 93 | } 94 | if (!csrfToken) { 95 | showError('Security token missing. Please refresh the page.'); 96 | return; 97 | } 98 | 99 | setLoadingState(true); 100 | 101 | try { 102 | const response = await fetch('/api/auth/emby/login', { 103 | method: 'POST', 104 | headers: { 105 | 'Content-Type': 'application/json', 106 | 'X-CSRFToken': csrfToken 107 | }, 108 | body: JSON.stringify({ username, password }), 109 | }); 110 | 111 | if (response.ok) { 112 | const result = await response.json(); 113 | if (result.success) { 114 | const nextUrlInput = document.querySelector('input[name="next"]'); 115 | window.location.href = nextUrlInput ? nextUrlInput.value : '/'; 116 | } else { 117 | showError(result.message || 'Authentication failed.'); 118 | } 119 | } else { 120 | let errorMessage = `Authentication failed (Status: ${response.status})`; 121 | if (response.status === 400 && response.headers.get('Content-Type')?.includes('text/html')) { 122 | errorMessage = 'Security token validation failed. Please refresh the page and try again.'; 123 | } else { 124 | try { 125 | const errorResult = await response.json(); 126 | errorMessage = errorResult.message || errorMessage; 127 | } catch (e) { 128 | try { const textError = await response.text(); if (textError && !textError.trim().startsWith('<')) { errorMessage = textError.trim(); } } catch (e2) {} 129 | console.warn("Could not parse error response as JSON. Status:", response.status); 130 | } 131 | } 132 | showError(errorMessage); 133 | } 134 | } catch (error) { 135 | console.error('Error during Emby login fetch:', error); 136 | showError('A network error occurred.'); 137 | } finally { 138 | setLoadingState(false); 139 | } 140 | }); 141 | } 142 | 143 | if (embyAuthModal) { 144 | embyAuthModal.addEventListener('click', (event) => { 145 | if (event.target === embyAuthModal) { 146 | hideModal(); 147 | } 148 | }); 149 | } 150 | 151 | }); 152 | -------------------------------------------------------------------------------- /static/js/jellyfin-auth.js: -------------------------------------------------------------------------------- 1 | class JellyfinAuth { 2 | constructor() { 3 | this.modalElement = document.getElementById('jellyfin-auth-modal'); 4 | this.formElement = document.getElementById('jellyfin-auth-form'); 5 | this.usernameInput = document.getElementById('jellyfin-username'); 6 | this.passwordInput = document.getElementById('jellyfin-password'); 7 | this.loginButton = document.getElementById('jellyfin-login-btn'); 8 | this.cancelButton = document.getElementById('cancel-jellyfin-auth'); 9 | this.submitButton = this.formElement ? this.formElement.querySelector('button[type="submit"]') : null; 10 | 11 | document.addEventListener('DOMContentLoaded', () => this.initEventListeners()); 12 | } 13 | 14 | getCsrfToken() { 15 | const token = document.querySelector('meta[name="csrf-token"]'); 16 | return token ? token.getAttribute('content') : null; 17 | } 18 | 19 | initEventListeners() { 20 | if (this.loginButton) { 21 | this.loginButton.addEventListener('click', () => this.showModal()); 22 | } 23 | 24 | if (this.formElement) { 25 | this.formElement.addEventListener('submit', (event) => this.handleSubmit(event)); 26 | } 27 | 28 | if (this.cancelButton) { 29 | this.cancelButton.addEventListener('click', () => this.hideModal()); 30 | } 31 | 32 | if (this.modalElement) { 33 | this.modalElement.addEventListener('click', (event) => { 34 | if (event.target === this.modalElement) { 35 | this.hideModal(); 36 | } 37 | }); 38 | } 39 | } 40 | 41 | showModal() { 42 | if (this.modalElement) { 43 | this.clearError(); 44 | this.usernameInput.value = ''; 45 | this.passwordInput.value = ''; 46 | this.modalElement.style.display = 'flex'; 47 | this.usernameInput.focus(); 48 | } 49 | } 50 | 51 | hideModal() { 52 | if (this.modalElement) { 53 | this.modalElement.style.display = 'none'; 54 | } 55 | } 56 | 57 | async handleSubmit(event) { 58 | event.preventDefault(); 59 | 60 | const username = this.usernameInput.value.trim(); 61 | const password = this.passwordInput.value; 62 | const csrfToken = this.getCsrfToken(); 63 | 64 | if (!username || !password) { 65 | this.showError('Username and password are required.'); 66 | return; 67 | } 68 | if (!csrfToken) { 69 | this.showError('Security token missing. Please refresh the page.'); 70 | return; 71 | } 72 | 73 | 74 | this.setLoading(true); 75 | 76 | try { 77 | const headers = { 78 | 'Content-Type': 'application/json', 79 | 'Accept': 'application/json', 80 | 'X-CSRFToken': csrfToken 81 | }; 82 | 83 | const response = await fetch('/api/auth/jellyfin/login', { 84 | method: 'POST', 85 | headers: headers, 86 | body: JSON.stringify({ username, password }) 87 | }); 88 | 89 | if (response.ok) { 90 | const result = await response.json(); 91 | if (result.success) { 92 | this.showSuccessAndRedirect(); 93 | } else { 94 | this.showError(result.message || 'Jellyfin authentication failed.'); 95 | } 96 | } else { 97 | let errorMessage = `Authentication failed (Status: ${response.status})`; 98 | if (response.status === 400 && response.headers.get('Content-Type')?.includes('text/html')) { 99 | errorMessage = 'Security token validation failed. Please refresh the page and try again.'; 100 | } else { 101 | try { 102 | const errorResult = await response.json(); 103 | errorMessage = errorResult.message || errorMessage; 104 | } catch (e) { 105 | try { 106 | const textError = await response.text(); 107 | if (textError && !textError.trim().startsWith('<')) { 108 | errorMessage = textError.trim(); // Trim whitespace 109 | } 110 | } catch (e2) { 111 | } 112 | console.warn("Could not parse Jellyfin error response as JSON. Status:", response.status); 113 | } 114 | } 115 | this.showError(errorMessage); 116 | } 117 | 118 | } catch (error) { 119 | console.error('Jellyfin auth error:', error); 120 | this.showError(error.message || 'A network or unexpected error occurred.'); 121 | } finally { 122 | this.setLoading(false); 123 | } 124 | } 125 | 126 | setLoading(isLoading) { 127 | if (this.submitButton && this.cancelButton && this.usernameInput && this.passwordInput) { 128 | this.submitButton.disabled = isLoading; 129 | this.cancelButton.disabled = isLoading; 130 | this.usernameInput.disabled = isLoading; 131 | this.passwordInput.disabled = isLoading; 132 | 133 | if (isLoading) { 134 | this.submitButton.innerHTML = ' Signing In...'; 135 | } else { 136 | this.submitButton.innerHTML = 'Sign In'; 137 | } 138 | } 139 | } 140 | 141 | showError(message) { 142 | this.clearError(); 143 | const errorDiv = document.createElement('div'); 144 | errorDiv.className = 'error-message modal-error'; 145 | errorDiv.innerHTML = ` ${message}`; 146 | 147 | const formButtons = this.formElement.querySelector('.form-buttons'); 148 | if (formButtons) { 149 | this.formElement.insertBefore(errorDiv, formButtons); 150 | } else { 151 | this.formElement.appendChild(errorDiv); 152 | } 153 | } 154 | 155 | clearError() { 156 | const existingError = this.formElement.querySelector('.modal-error'); 157 | if (existingError) { 158 | existingError.remove(); 159 | } 160 | } 161 | 162 | showSuccessAndRedirect() { 163 | if (this.modalElement) { 164 | const modalContent = this.modalElement.querySelector('.modal-content'); 165 | if (modalContent) { 166 | modalContent.innerHTML = ` 167 |

Authentication Successful

168 |

You've successfully logged in with Jellyfin!

169 | 173 | `; 174 | } 175 | setTimeout(() => { 176 | const nextUrlInput = document.querySelector('input[name="next"]'); 177 | const redirectUrl = nextUrlInput ? nextUrlInput.value : '/'; 178 | window.location.href = redirectUrl; 179 | }, 1500); 180 | } 181 | } 182 | } 183 | 184 | const jellyfinAuth = new JellyfinAuth(); 185 | -------------------------------------------------------------------------------- /static/js/plex-auth.js: -------------------------------------------------------------------------------- 1 | function getClientId() { 2 | let clientId = localStorage.getItem('plex-client-id'); 3 | if (!clientId) { 4 | clientId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { 5 | const r = Math.random() * 16 | 0; 6 | const v = c === 'x' ? r : (r & 0x3 | 0x8); 7 | return v.toString(16); 8 | }); 9 | localStorage.setItem('plex-client-id', clientId); 10 | } 11 | return clientId; 12 | } 13 | 14 | class PlexAuth { 15 | constructor() { 16 | this.pinCheckInterval = null; 17 | this.pinId = null; 18 | this.authToken = null; 19 | this.plexWindow = null; 20 | this.modalElement = document.getElementById('plex-auth-modal'); 21 | this.maxPinCheckAttempts = 30; 22 | this.pinCheckAttempts = 0; 23 | 24 | document.addEventListener('DOMContentLoaded', () => this.initEventListeners()); 25 | } 26 | 27 | initEventListeners() { 28 | const plexLoginBtn = document.getElementById('plex-login-btn'); 29 | const cancelPlexAuth = document.getElementById('cancel-plex-auth'); 30 | 31 | if (plexLoginBtn) { 32 | plexLoginBtn.addEventListener('click', () => this.startAuth()); 33 | } 34 | 35 | if (cancelPlexAuth) { 36 | cancelPlexAuth.addEventListener('click', () => this.cancelAuth()); 37 | } 38 | } 39 | 40 | startAuth() { 41 | this.preparePopup(); 42 | 43 | setTimeout(() => this.requestPlexAuth(), 500); 44 | } 45 | 46 | preparePopup() { 47 | const width = 600; 48 | const height = 700; 49 | const left = (window.screen.width / 2) - (width / 2); 50 | const top = (window.screen.height / 2) - (height / 2); 51 | 52 | this.plexWindow = window.open( 53 | 'about:blank', 54 | 'PlexAuth', 55 | `width=${width},height=${height},top=${top},left=${left}` 56 | ); 57 | 58 | if (this.modalElement) { 59 | this.modalElement.style.display = 'flex'; 60 | } 61 | } 62 | 63 | async requestPlexAuth() { 64 | try { 65 | const clientId = getClientId(); 66 | 67 | const response = await fetch('/plex/auth', { 68 | method: 'GET', 69 | headers: { 70 | 'Accept': 'application/json', 71 | 'X-Plex-Client-Identifier': clientId 72 | } 73 | }); 74 | 75 | if (!response.ok) { 76 | throw new Error(`HTTP error ${response.status}`); 77 | } 78 | 79 | const data = await response.json(); 80 | 81 | if (!data.auth_url) { 82 | throw new Error('Invalid response from server'); 83 | } 84 | 85 | this.pinId = data.pin_id; 86 | console.log(`Starting authentication with PIN ID: ${this.pinId}`); 87 | 88 | if (this.plexWindow && !this.plexWindow.closed) { 89 | this.plexWindow.location.href = data.auth_url; 90 | 91 | this.pinCheckAttempts = 0; 92 | 93 | this.pinCheckInterval = setInterval(() => this.checkPlexPin(), 2000); 94 | 95 | const windowCheckInterval = setInterval(() => { 96 | if (this.plexWindow.closed) { 97 | clearInterval(windowCheckInterval); 98 | this.cancelAuth(); 99 | } 100 | }, 1000); 101 | } else { 102 | throw new Error('Authentication window was closed'); 103 | } 104 | } catch (error) { 105 | console.error('Error starting Plex auth:', error); 106 | this.showAuthError(error.message || 'An error occurred during authentication'); 107 | } 108 | } 109 | 110 | async checkPlexPin() { 111 | if (!this.pinId) return; 112 | 113 | try { 114 | this.pinCheckAttempts++; 115 | 116 | if (this.pinCheckAttempts > this.maxPinCheckAttempts) { 117 | clearInterval(this.pinCheckInterval); 118 | throw new Error('Authentication timed out. Please try again.'); 119 | } 120 | 121 | const response = await fetch(`/api/auth/plex/check_pin/${this.pinId}`); 122 | 123 | if (!response.ok) { 124 | throw new Error(`HTTP error ${response.status}`); 125 | } 126 | 127 | const data = await response.json(); 128 | 129 | if (data.status === 'success') { 130 | clearInterval(this.pinCheckInterval); 131 | 132 | if (data.token) { 133 | this.authToken = data.token; 134 | localStorage.setItem('plex-auth-token', data.token); 135 | } 136 | 137 | this.showAuthSuccess(); 138 | 139 | console.log('Authentication successful, redirecting...'); 140 | 141 | if (this.plexWindow && !this.plexWindow.closed) { 142 | this.plexWindow.close(); 143 | } 144 | 145 | setTimeout(() => { 146 | let redirectUrl = `/plex/callback?pinID=${this.pinId}`; 147 | if (this.authToken) { 148 | redirectUrl += `&token=${encodeURIComponent(this.authToken)}`; 149 | } 150 | window.location.href = redirectUrl; 151 | }, 1500); 152 | } else { 153 | console.log(`PIN check result: ${data.status} - ${data.message}`); 154 | } 155 | } catch (error) { 156 | console.error('Error checking PIN:', error); 157 | 158 | if (this.pinCheckAttempts >= this.maxPinCheckAttempts) { 159 | this.showAuthError(`Authentication error: ${error.message}`); 160 | this.cancelAuth(); 161 | } 162 | } 163 | } 164 | 165 | cancelAuth() { 166 | if (this.pinCheckInterval) { 167 | clearInterval(this.pinCheckInterval); 168 | this.pinCheckInterval = null; 169 | } 170 | 171 | if (this.modalElement) { 172 | this.modalElement.style.display = 'none'; 173 | } 174 | 175 | if (this.plexWindow && !this.plexWindow.closed) { 176 | this.plexWindow.close(); 177 | this.plexWindow = null; 178 | } 179 | } 180 | 181 | showAuthError(message) { 182 | if (this.modalElement) { 183 | const modalContent = this.modalElement.querySelector('.modal-content'); 184 | if (modalContent) { 185 | modalContent.innerHTML = ` 186 |

Authentication Error

187 |

${message}

188 | 189 | `; 190 | 191 | document.getElementById('close-error').addEventListener('click', () => this.cancelAuth()); 192 | } 193 | } 194 | } 195 | 196 | showAuthSuccess() { 197 | if (this.modalElement) { 198 | const modalContent = this.modalElement.querySelector('.modal-content'); 199 | if (modalContent) { 200 | modalContent.innerHTML = ` 201 |

Authentication Successful

202 |

You've successfully logged in with Plex!

203 | 207 | `; 208 | } 209 | } 210 | } 211 | } 212 | 213 | const plexAuth = new PlexAuth(); 214 | -------------------------------------------------------------------------------- /static/js/service-worker.js: -------------------------------------------------------------------------------- 1 | const CACHE_NAME = 'static-cache-v6'; 2 | const ASSETS_TO_CACHE = [ 3 | '/', 4 | '/static/js/script.js', 5 | '/static/style/style.css', 6 | '/static/icons/icon-192x192.png', 7 | '/static/icons/icon-512x512.png', 8 | '/static/manifest.json', 9 | 'https://cdn.jsdelivr.net/npm/ios-pwa-splash@1.0.0/cdn.min.js' 10 | ]; 11 | 12 | self.addEventListener('install', (event) => { 13 | event.waitUntil( 14 | caches.open(CACHE_NAME).then((cache) => { 15 | return cache.addAll(ASSETS_TO_CACHE); 16 | }) 17 | ); 18 | }); 19 | 20 | self.addEventListener('activate', (event) => { 21 | event.waitUntil( 22 | caches.keys().then((cacheNames) => { 23 | return Promise.all( 24 | cacheNames.map((cacheName) => { 25 | if (cacheName !== CACHE_NAME) { 26 | return caches.delete(cacheName); 27 | } 28 | }) 29 | ); 30 | }) 31 | ); 32 | }); 33 | 34 | self.addEventListener('fetch', (event) => { 35 | event.respondWith( 36 | caches.match(event.request).then((response) => { 37 | return response || fetch(event.request); 38 | }) 39 | ); 40 | }); 41 | -------------------------------------------------------------------------------- /static/js/settings_managed_users.js: -------------------------------------------------------------------------------- 1 | window.openAddManagedUserModal = async function() { 2 | const existingModal = document.getElementById('addManagedUserModal'); 3 | if (existingModal) { 4 | existingModal.remove(); 5 | } 6 | 7 | let availableUsers = []; 8 | try { 9 | const response = await fetch('/api/settings/managed_users/available'); 10 | if (!response.ok) { 11 | throw new Error(`HTTP error! status: ${response.status}`); 12 | } 13 | availableUsers = await response.json(); 14 | } catch (error) { 15 | console.error('Error fetching available managed users:', error); 16 | const errorMessage = `Error fetching available users: ${error.message || 'Please check server logs.'}`; 17 | if (typeof showError === 'function') { 18 | showError(errorMessage); 19 | } else { 20 | console.warn('showError function not found, using alert fallback.'); 21 | alert(errorMessage); 22 | } 23 | return; 24 | } 25 | 26 | if (availableUsers.length === 0) { 27 | const noUsersMessage = 'No available Plex managed users found to add.'; 28 | if (typeof showMessage === 'function') { 29 | showMessage(noUsersMessage); 30 | } else { 31 | console.warn('showMessage function not found, using alert fallback.'); 32 | alert(noUsersMessage); 33 | } 34 | return; 35 | } 36 | 37 | const modalHTML = ` 38 |
39 |
40 |

Add Managed User

41 | 42 |
43 |
44 | 45 | 49 |
50 |
51 | 52 | 53 | Username is based on the selected Plex user. 54 |
55 |
56 | 57 | 58 | Minimum 6 characters. 59 |
60 |
61 |
62 | 63 | 64 |
65 |
66 |
67 | 68 | `; 69 | 70 | document.body.insertAdjacentHTML('beforeend', modalHTML); 71 | 72 | const modalElement = document.getElementById('addManagedUserModal'); 73 | if (!modalElement) { 74 | console.error("Failed to find modal element (#addManagedUserModal) after insertion!"); 75 | return; 76 | } 77 | 78 | function closeModal() { 79 | const modalToClose = document.getElementById('addManagedUserModal'); 80 | if (modalToClose) { 81 | modalToClose.remove(); 82 | } 83 | } 84 | 85 | modalElement.addEventListener('click', (e) => { 86 | if (e.target === modalElement) { 87 | closeModal(); 88 | } 89 | }); 90 | 91 | if (modalElement) { 92 | const userSelect = modalElement.querySelector('#managedUserSelect'); 93 | const usernameInput = modalElement.querySelector('#managedUsernameInput'); 94 | const cancelBtn = modalElement.querySelector('#cancelAddManagedUserBtn'); 95 | const confirmBtn = modalElement.querySelector('#confirmAddManagedUserBtn'); 96 | const passwordInput = modalElement.querySelector('#managedUserPassword'); 97 | 98 | if (userSelect && usernameInput) { 99 | userSelect.addEventListener('change', (event) => { 100 | const selectedOption = event.target.selectedOptions[0]; 101 | if (selectedOption) { 102 | const username = selectedOption.getAttribute('data-username'); 103 | usernameInput.value = username || ''; 104 | } else { 105 | usernameInput.value = ''; 106 | } 107 | }); 108 | } else { 109 | console.error("Could not find user select or username input elements in modal."); 110 | } 111 | 112 | if (cancelBtn) { 113 | cancelBtn.addEventListener('click', () => { 114 | closeModal(); 115 | }); 116 | } else { 117 | console.error("Could not find cancel button in modal."); 118 | } 119 | 120 | if (confirmBtn) { 121 | confirmBtn.addEventListener('click', async () => { 122 | const form = modalElement.querySelector('#addManagedUserForm'); 123 | const errorDiv = modalElement.querySelector('#addManagedUserError'); 124 | const currentSelectedOption = modalElement.querySelector('#managedUserSelect').selectedOptions[0]; 125 | const currentUsername = modalElement.querySelector('#managedUsernameInput').value; 126 | const currentPassword = modalElement.querySelector('#managedUserPassword').value; 127 | 128 | errorDiv.style.display = 'none'; 129 | 130 | if (!form.checkValidity()) { 131 | form.reportValidity(); 132 | return; 133 | } 134 | 135 | const selectedOption = userSelect.selectedOptions[0]; 136 | const plexUserId = selectedOption ? selectedOption.value : null; 137 | const username = usernameInput.value; 138 | const password = passwordInput.value; 139 | 140 | if (!plexUserId) { 141 | errorDiv.textContent = 'Please select a Plex user.'; 142 | errorDiv.style.display = 'block'; 143 | return; 144 | } 145 | 146 | if (!password || password.length < 6) { 147 | errorDiv.textContent = 'Password must be at least 6 characters long.'; 148 | errorDiv.style.display = 'block'; 149 | passwordInput.focus(); 150 | return; 151 | } 152 | 153 | confirmBtn.disabled = true; 154 | confirmBtn.innerHTML = ' Adding...'; 155 | 156 | try { 157 | const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); 158 | const response = await fetch('/api/settings/managed_users', { 159 | method: 'POST', 160 | headers: { 161 | 'Content-Type': 'application/json', 162 | 'X-CSRFToken': csrfToken 163 | }, 164 | body: JSON.stringify({ 165 | plex_user_id: plexUserId, 166 | username: username, 167 | password: password 168 | }) 169 | }); 170 | 171 | if (!response.ok) { 172 | const errorData = await response.json(); 173 | throw new Error(errorData.error || `HTTP error! status: ${response.status}`); 174 | } 175 | 176 | const newUser = await response.json(); 177 | closeModal(); 178 | 179 | document.dispatchEvent(new CustomEvent('managedUserAdded')); 180 | 181 | } catch (error) { 182 | console.error('Error adding managed user:', error); 183 | errorDiv.textContent = error.message || 'An unexpected error occurred.'; 184 | errorDiv.style.display = 'block'; 185 | } finally { 186 | confirmBtn.disabled = false; 187 | confirmBtn.innerHTML = 'Add User'; 188 | } 189 | }); 190 | } else { 191 | console.error("Could not find confirm button in modal."); 192 | } 193 | } 194 | 195 | modalElement.style.display = 'flex'; 196 | 197 | } -------------------------------------------------------------------------------- /static/js/user_cache.js: -------------------------------------------------------------------------------- 1 | class UserCacheProfile { 2 | constructor() { 3 | this.containerElement = null; 4 | this.username = null; 5 | this.isAdmin = false; 6 | this.cacheStats = null; 7 | this.currentService = null; 8 | } 9 | 10 | init() { 11 | if (!document.getElementById('userCacheProfile')) { 12 | const container = document.createElement('div'); 13 | container.id = 'userCacheProfile'; 14 | container.className = 'user-cache-profile hidden'; 15 | 16 | const headerEl = document.querySelector('.header'); 17 | if (headerEl) { 18 | headerEl.appendChild(container); 19 | this.containerElement = container; 20 | 21 | this.addStyles(); 22 | 23 | this.loadUserProfile(); 24 | } 25 | } 26 | } 27 | 28 | addStyles() { 29 | if (!document.getElementById('userCacheProfileStyles')) { 30 | const styles = document.createElement('style'); 31 | styles.id = 'userCacheProfileStyles'; 32 | styles.textContent = ` 33 | .user-cache-profile { 34 | position: absolute; 35 | top: 60px; 36 | right: 20px; 37 | background-color: #2a2c30; 38 | border-radius: 10px; 39 | padding: 15px; 40 | box-shadow: 0 2px 10px rgba(0,0,0,0.3); 41 | z-index: 100; 42 | min-width: 250px; 43 | max-width: 350px; 44 | } 45 | 46 | .user-cache-profile.hidden { 47 | display: none; 48 | } 49 | 50 | .user-cache-profile h3 { 51 | margin-top: 0; 52 | border-bottom: 1px solid #444; 53 | padding-bottom: 8px; 54 | margin-bottom: 12px; 55 | } 56 | 57 | .user-cache-profile .cache-stat { 58 | display: flex; 59 | justify-content: space-between; 60 | margin-bottom: 5px; 61 | } 62 | 63 | .user-cache-profile .service-stats { 64 | margin-top: 10px; 65 | } 66 | 67 | .user-cache-profile .service-stats h4 { 68 | margin: 5px 0; 69 | color: #e5a00d; 70 | text-transform: capitalize; 71 | } 72 | 73 | .user-cache-profile .action-buttons { 74 | display: flex; 75 | justify-content: space-between; 76 | margin-top: 15px; 77 | } 78 | 79 | .user-cache-profile .action-button { 80 | padding: 6px 12px; 81 | border-radius: 4px; 82 | border: none; 83 | cursor: pointer; 84 | font-weight: 600; 85 | background-color: #4a90e2; 86 | color: white; 87 | } 88 | 89 | .user-cache-profile .action-button:hover { 90 | opacity: 0.9; 91 | } 92 | 93 | .user-cache-profile .admin-link { 94 | display: block; 95 | text-align: center; 96 | margin-top: 10px; 97 | color: #e5a00d; 98 | text-decoration: none; 99 | } 100 | 101 | .user-cache-profile .admin-link:hover { 102 | text-decoration: underline; 103 | } 104 | 105 | .user-info { 106 | position: relative; 107 | display: inline-block; 108 | margin-left: 15px; 109 | } 110 | 111 | .user-info-button { 112 | background: none; 113 | border: none; 114 | color: white; 115 | font-size: 16px; 116 | cursor: pointer; 117 | padding: 5px; 118 | display: flex; 119 | align-items: center; 120 | } 121 | 122 | .user-info-button svg { 123 | margin-right: 5px; 124 | } 125 | `; 126 | 127 | document.head.appendChild(styles); 128 | } 129 | 130 | if (!document.querySelector('.user-info')) { 131 | const navEl = document.querySelector('.navigation'); 132 | if (navEl) { 133 | const userInfo = document.createElement('div'); 134 | userInfo.className = 'user-info'; 135 | userInfo.innerHTML = ` 136 | 143 | `; 144 | 145 | navEl.appendChild(userInfo); 146 | 147 | const userInfoButton = userInfo.querySelector('.user-info-button'); 148 | userInfoButton.addEventListener('click', () => { 149 | this.toggleProfile(); 150 | }); 151 | 152 | document.addEventListener('click', (e) => { 153 | if (!userInfo.contains(e.target) && this.containerElement) { 154 | this.containerElement.classList.add('hidden'); 155 | } 156 | }); 157 | } 158 | } 159 | } 160 | 161 | async loadUserProfile() { 162 | try { 163 | const response = await fetch('/api/profile'); 164 | 165 | if (!response.ok) { 166 | return; 167 | } 168 | 169 | const data = await response.json(); 170 | 171 | if (data.authenticated) { 172 | this.username = data.username; 173 | this.isAdmin = data.is_admin; 174 | this.cacheStats = data.cache_stats; 175 | this.currentService = data.current_service; 176 | 177 | const usernameEl = document.querySelector('.user-info .username'); 178 | if (usernameEl) { 179 | usernameEl.textContent = this.username; 180 | } 181 | 182 | this.renderProfileContent(); 183 | } 184 | } catch (error) { 185 | console.error('Error loading user profile:', error); 186 | } 187 | } 188 | 189 | renderProfileContent() { 190 | if (!this.containerElement) return; 191 | 192 | let content = ` 193 |

User Profile: ${this.username}

194 |
195 | Current Service: 196 | ${this.currentService} 197 |
198 | `; 199 | 200 | if (this.cacheStats) { 201 | content += `
`; 202 | 203 | const currentServiceStats = this.cacheStats[this.currentService]; 204 | if (currentServiceStats) { 205 | content += ` 206 |

${this.currentService}

207 | ${this.currentService === 'plex' ? ` 208 |
209 | Unwatched Movies: 210 | ${currentServiceStats.unwatched_count} 211 |
212 | ` : ''} 213 |
214 | All Movies: 215 | ${currentServiceStats.all_count} 216 |
217 |
218 | Cache Exists: 219 | ${currentServiceStats.cache_exists ? 'Yes' : 'No'} 220 |
221 | `; 222 | } 223 | 224 | content += `
`; 225 | } 226 | 227 | content += ` 228 |
229 | 230 | 231 |
232 | `; 233 | 234 | if (this.isAdmin) { 235 | content += ` 236 | User Cache Administration 237 | `; 238 | } 239 | 240 | this.containerElement.innerHTML = content; 241 | } 242 | 243 | toggleProfile() { 244 | if (!this.containerElement) return; 245 | 246 | this.containerElement.classList.toggle('hidden'); 247 | 248 | if (!this.containerElement.classList.contains('hidden')) { 249 | this.loadUserProfile(); 250 | } 251 | } 252 | 253 | async refreshCache() { 254 | try { 255 | const response = await fetch('/api/user_cache/refresh_current'); 256 | const data = await response.json(); 257 | 258 | if (data.success) { 259 | alert('Cache refresh started!'); 260 | 261 | this.containerElement.classList.add('hidden'); 262 | } else { 263 | alert(data.message || 'Failed to refresh cache'); 264 | } 265 | } catch (error) { 266 | console.error('Error refreshing cache:', error); 267 | alert('Error refreshing cache'); 268 | } 269 | } 270 | 271 | async clearCache() { 272 | if (!confirm('Are you sure you want to clear your cache? This will remove all cached movies.')) { 273 | return; 274 | } 275 | 276 | try { 277 | const response = await fetch(`/api/user_cache/clear/${this.username}`); 278 | const data = await response.json(); 279 | 280 | if (data.success) { 281 | alert('Cache cleared successfully!'); 282 | 283 | this.containerElement.classList.add('hidden'); 284 | 285 | window.location.reload(); 286 | } else { 287 | alert(data.message || 'Failed to clear cache'); 288 | } 289 | } catch (error) { 290 | console.error('Error clearing cache:', error); 291 | alert('Error clearing cache'); 292 | } 293 | } 294 | } 295 | 296 | const userCacheProfile = new UserCacheProfile(); 297 | 298 | document.addEventListener('DOMContentLoaded', () => { 299 | if (typeof auth_manager !== 'undefined' && auth_manager.auth_enabled) { 300 | userCacheProfile.init(); 301 | } 302 | }); 303 | 304 | window.userCacheProfile = userCacheProfile; 305 | -------------------------------------------------------------------------------- /static/logos/imdb_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /static/logos/tmdb_logo.svg: -------------------------------------------------------------------------------- 1 | Asset 2 2 | -------------------------------------------------------------------------------- /static/logos/trakt_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /static/logos/youtube_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Movie Roulette", 3 | "short_name": "Movie Roulette", 4 | "description": "Discover random movies from your collection", 5 | "start_url": "/", 6 | "display": "standalone", 7 | "background_color": "#000000", 8 | "theme_color": "#000000", 9 | "orientation": "portrait", 10 | "icons": [ 11 | { 12 | "src": "/static/icons/icon-192x192.png", 13 | "sizes": "192x192", 14 | "type": "image/png", 15 | "purpose": "any maskable" 16 | }, 17 | { 18 | "src": "/static/icons/icon-512x512.png", 19 | "sizes": "512x512", 20 | "type": "image/png", 21 | "purpose": "any maskable" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /static/style/poster.css: -------------------------------------------------------------------------------- 1 | /* Base styles */ 2 | :root { 3 | --info-height-top: 70px; 4 | --info-height-bottom: 50px; 5 | --total-info-height: calc(var(--info-height-top) + var(--info-height-bottom)); 6 | --info-height-top-portrait: 60px; 7 | --info-height-bottom-portrait: 40px; 8 | --total-info-height-portrait: calc(var(--info-height-top-portrait) + var(--info-height-bottom-portrait)); 9 | --viewport-height: 100vh; 10 | --safe-height: 100vh; 11 | } 12 | 13 | body, html { 14 | margin: 0; 15 | padding: 0; 16 | height: 100vh; 17 | background-color: #000; 18 | color: #FFD700; 19 | font-family: Arial, sans-serif; 20 | display: flex; 21 | justify-content: center; 22 | align-items: center; 23 | overflow: hidden; 24 | } 25 | 26 | .poster-container { 27 | position: relative; 28 | width: 100%; 29 | height: 100vh; 30 | height: 100dvh; 31 | padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); 32 | display: flex; 33 | flex-direction: column; 34 | align-items: center; 35 | background-color: #000; 36 | } 37 | 38 | /* Movie Info Elements */ 39 | .movie-info { 40 | display: flex; 41 | z-index: 2; 42 | width: 100%; 43 | } 44 | 45 | .info-bar { 46 | display: flex; 47 | justify-content: space-between; 48 | align-items: center; 49 | background-color: rgba(0, 0, 0, 0.7); 50 | padding: 10px; 51 | width: 100%; 52 | box-sizing: border-box; 53 | } 54 | 55 | .time-info { 56 | display: flex; 57 | flex-direction: column; 58 | align-items: flex-start; 59 | } 60 | 61 | .time-info.end { 62 | align-items: flex-end; 63 | } 64 | 65 | .time-label { 66 | font-size: 0.8em; 67 | } 68 | 69 | .time { 70 | font-size: 1.2em; 71 | } 72 | 73 | .playback-status { 74 | font-size: 1.5em; 75 | font-weight: bold; 76 | text-align: center; 77 | flex-grow: 1; 78 | margin: 0 20px; 79 | } 80 | 81 | .paused { color: #FFA500; } 82 | .ending { color: #FF4500; } 83 | .stopped { color: #DC143C; } 84 | 85 | .progress-container { 86 | width: 100%; 87 | background-color: #333; 88 | height: 5px; 89 | z-index: 2; 90 | } 91 | 92 | .progress-bar { 93 | width: 0%; 94 | height: 100%; 95 | background-color: #FFD700; 96 | transition: width 0.5s ease; 97 | } 98 | 99 | /* Poster image */ 100 | .poster-image { 101 | width: auto; 102 | height: calc(100vh - var(--total-info-height)); 103 | object-fit: contain; 104 | transition: opacity 0.5s ease-in-out; 105 | z-index: 1; 106 | max-width: 100%; 107 | background-color: #000; 108 | max-height: 100dvh; 109 | } 110 | 111 | .bottom-info { 112 | display: flex; 113 | justify-content: center; 114 | align-items: center; 115 | padding: 10px; 116 | background-color: rgba(0, 0, 0, 0.7); 117 | width: 100%; 118 | box-sizing: border-box; 119 | z-index: 2; 120 | } 121 | 122 | .info-item { 123 | text-align: center; 124 | margin: 0 20px; 125 | } 126 | 127 | .info-item-rotating { 128 | text-align: center; 129 | margin: 0 20px; 130 | } 131 | 132 | .info-label { 133 | font-size: 0.8em; 134 | display: block; 135 | } 136 | 137 | .info-value { 138 | font-size: 1.2em; 139 | } 140 | 141 | /* Custom Text Container */ 142 | .custom-text-container { 143 | position: absolute; 144 | top: 24.475%; 145 | left: 30.62%; 146 | width: 38.96%; 147 | height: 17.04%; 148 | display: none; 149 | justify-content: center; 150 | align-items: center; 151 | text-align: center; 152 | overflow: hidden; 153 | z-index: 3; 154 | } 155 | 156 | .custom-text { 157 | color: white; 158 | line-height: 1.2; 159 | white-space: pre-wrap; 160 | text-shadow: 2px 2px 4px rgba(0,0,0,0.5); 161 | word-break: break-word; 162 | margin: 0; 163 | } 164 | 165 | /* Special Modes */ 166 | /* Default Poster Mode */ 167 | .default-poster .poster-image { 168 | max-height: 100vh; 169 | width: auto; 170 | height: 100vh; 171 | } 172 | 173 | .default-poster .movie-info { 174 | display: none; 175 | } 176 | 177 | .default-poster .custom-text-container { 178 | display: flex; 179 | } 180 | 181 | /* Screensaver Mode */ 182 | .screensaver .poster-image { 183 | max-height: 100vh; 184 | width: auto; 185 | height: 100vh; 186 | } 187 | 188 | .screensaver .movie-info, 189 | .screensaver .custom-text-container { 190 | display: none; 191 | } 192 | 193 | /* Override for active movie mode */ 194 | .poster-container:not(.default-poster):not(.screensaver) .custom-text-container { 195 | display: none !important; 196 | } 197 | 198 | /* Media Queries */ 199 | @media (orientation: portrait) { 200 | .poster-container:not(.screensaver):not(.default-poster) .poster-image { 201 | height: calc(100vh - var(--total-info-height-portrait)); 202 | } 203 | 204 | .info-bar, 205 | .bottom-info { 206 | padding: 5px; 207 | } 208 | 209 | .time-label { 210 | font-size: 0.7em; 211 | } 212 | 213 | .time, 214 | .info-value { 215 | font-size: 1em; 216 | } 217 | 218 | .playback-status { 219 | font-size: 1.2em; 220 | margin: 0 10px; 221 | } 222 | 223 | .info-item { 224 | margin: 0 10px; 225 | } 226 | 227 | .custom-text { 228 | font-size: 3vw; 229 | } 230 | 231 | .custom-text-container { 232 | top: 24.475%; 233 | left: 30.62%; 234 | width: 38.96%; 235 | height: 17.04%; 236 | } 237 | } 238 | 239 | @media (orientation: landscape) { 240 | .custom-text { 241 | font-size: 2vw; 242 | } 243 | } 244 | 245 | @media (orientation: portrait) and (max-width: 600px) { 246 | .custom-text-container { 247 | top: 20%; 248 | left: 25%; 249 | width: 50%; 250 | height: 20%; 251 | } 252 | 253 | .info-bar, 254 | .bottom-info { 255 | padding: 3px; 256 | } 257 | 258 | .info-item { 259 | margin: 0 5px; 260 | } 261 | 262 | .playback-status { 263 | margin: 0 5px; 264 | } 265 | } 266 | @media screen and (max-width: 768px) { 267 | .poster-container { 268 | min-height: var(--safe-height); 269 | height: var(--safe-height); 270 | min-height: -webkit-fill-available; 271 | height: -webkit-fill-available; 272 | display: flex; 273 | flex-direction: column; 274 | } 275 | 276 | .poster-image { 277 | flex: 1; 278 | width: auto; 279 | height: auto; 280 | max-height: calc(100vh - var(--total-info-height)); 281 | object-fit: contain; 282 | } 283 | 284 | /* PWA specific adjustments */ 285 | @media all and (display-mode: standalone) { 286 | .poster-container { 287 | height: 100vh !important; 288 | height: 100dvh !important; 289 | padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); 290 | min-height: -webkit-fill-available; 291 | } 292 | 293 | .default-poster .poster-image, 294 | .screensaver .poster-image { 295 | height: 100vh !important; 296 | height: 100dvh !important; 297 | max-height: 100dvh !important; 298 | width: auto; 299 | object-fit: contain; 300 | } 301 | 302 | .movie-info { 303 | position: relative; 304 | z-index: 2; 305 | } 306 | } 307 | 308 | /* Handle orientation changes */ 309 | @media screen and (orientation: portrait) { 310 | .poster-container:not(.default-poster):not(.screensaver) .poster-image { 311 | max-height: calc(100vh - var(--total-info-height-portrait)); 312 | } 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/utils/__init__.py -------------------------------------------------------------------------------- /utils/__pycache__/__init__.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/utils/__pycache__/__init__.cpython-311.pyc -------------------------------------------------------------------------------- /utils/__pycache__/plex_service.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahara101/Movie-Roulette/8a1b0bb97509f735d7c3ce0f687add20b4f3dd77/utils/__pycache__/plex_service.cpython-311.pyc -------------------------------------------------------------------------------- /utils/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from .manager import auth_manager 2 | from .routes import auth_bp 3 | 4 | # Export the auth manager and blueprint 5 | __all__ = ['auth_manager', 'auth_bp'] 6 | -------------------------------------------------------------------------------- /utils/auth/managed_user_routes.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from flask import Blueprint, request, jsonify, current_app, g 3 | from utils.auth.manager import auth_manager 4 | from utils.auth.db import AuthDB 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | managed_user_routes = Blueprint('managed_user_routes', __name__) 9 | 10 | @managed_user_routes.route('/api/settings/managed_users', methods=['GET']) 11 | @auth_manager.require_admin 12 | def get_managed_users_route(): 13 | """Get all managed users stored in the database.""" 14 | try: 15 | users = auth_manager.db.get_all_managed_users() 16 | return jsonify(users), 200 17 | except Exception as e: 18 | logger.error(f"Error fetching managed users: {e}", exc_info=True) 19 | return jsonify({"error": "Failed to fetch managed users"}), 500 20 | 21 | @managed_user_routes.route('/api/settings/managed_users', methods=['POST']) 22 | @auth_manager.require_admin 23 | def add_managed_user_route(): 24 | """Add a new managed user with a password.""" 25 | data = request.get_json() 26 | if not data or 'plex_user_id' not in data or 'username' not in data or 'password' not in data: 27 | return jsonify({"error": "Missing required fields (plex_user_id, username, password)"}), 400 28 | 29 | plex_user_id = data['plex_user_id'] 30 | username = data['username'] 31 | password = data['password'] 32 | 33 | if not isinstance(password, str) or len(password) < 6: 34 | return jsonify({"error": "Password must be at least 6 characters long"}), 400 35 | 36 | try: 37 | if auth_manager.db.is_plex_user_added(plex_user_id): 38 | return jsonify({"error": "This Plex user is already added as a managed user."}), 409 39 | if auth_manager.db.get_managed_user_by_username(username): 40 | return jsonify({"error": "This username is already taken by another managed user."}), 409 41 | 42 | success, message = auth_manager.db.add_managed_user(username=username, password=password, plex_user_id=plex_user_id) 43 | if success: 44 | new_user_data = auth_manager.db.get_managed_user_by_username(username) 45 | safe_user_data = new_user_data.copy() if new_user_data else {} 46 | if 'password' in safe_user_data: 47 | del safe_user_data['password'] 48 | return jsonify(safe_user_data), 201 49 | else: 50 | return jsonify({"error": message or "Failed to add managed user"}), 500 51 | except Exception as e: 52 | logger.error(f"Error adding managed user {username}: {e}", exc_info=True) 53 | return jsonify({"error": "An internal error occurred while adding the managed user"}), 500 54 | 55 | @managed_user_routes.route('/api/settings/managed_users/', methods=['DELETE']) 56 | @auth_manager.require_admin 57 | def delete_managed_user_route(username): 58 | """Delete a managed user by their username.""" 59 | try: 60 | success, message = auth_manager.db.delete_managed_user(username) 61 | if success: 62 | return jsonify({"message": message}), 200 63 | else: 64 | return jsonify({"error": message or "Managed user not found"}), 404 65 | except Exception as e: 66 | logger.error(f"Error deleting managed user '{username}': {e}", exc_info=True) 67 | return jsonify({"error": "An internal error occurred while deleting the managed user"}), 500 68 | 69 | @managed_user_routes.route('/api/settings/managed_users/available', methods=['GET']) 70 | @auth_manager.require_admin 71 | def get_available_managed_users(): 72 | """Get Plex managed users that haven't been added to the password system yet.""" 73 | try: 74 | plex_service = current_app.config.get('PLEX_SERVICE') 75 | if not plex_service or not hasattr(plex_service, 'plex'): 76 | logger.error("Plex service instance or underlying plex connection not found in Flask app config.") 77 | return jsonify({"error": "Plex service is not configured or available."}), 503 78 | 79 | all_plex_users = [] 80 | try: 81 | admin_account = plex_service.plex.myPlexAccount() 82 | all_users_list = admin_account.users() 83 | 84 | all_plex_users = [] 85 | for user in all_users_list: 86 | is_managed = ( 87 | hasattr(user, 'home') and user.home and 88 | (not hasattr(user, 'email') or not user.email) 89 | ) 90 | 91 | if is_managed and hasattr(user, 'id') and hasattr(user, 'title'): 92 | logger.info(f"Identified managed user: {user.title} (ID: {user.id})") 93 | all_plex_users.append({'id': str(user.id), 'name': user.title}) 94 | else: 95 | pass 96 | 97 | logger.info(f"Successfully fetched and filtered {len(all_plex_users)} managed users from Plex.") 98 | 99 | except Exception as plex_api_error: 100 | logger.warning(f"Could not fetch or filter managed users from Plex API (possibly none exist or API error): {plex_api_error}", exc_info=False) 101 | pass 102 | 103 | added_users = auth_manager.db.get_all_managed_users() 104 | added_plex_ids = {user_data['plex_user_id'] for user_data in added_users.values() if user_data and 'plex_user_id' in user_data} 105 | 106 | available_users = [user for user in all_plex_users if user.get('id') not in added_plex_ids] 107 | 108 | return jsonify(available_users), 200 109 | 110 | except Exception as e: 111 | logger.error(f"Unexpected error fetching available managed users: {e}", exc_info=True) 112 | return jsonify({"error": "An unexpected error occurred while fetching available managed users"}), 500 113 | 114 | @managed_user_routes.route('/api/managed_user/change_password', methods=['POST']) 115 | @auth_manager.require_auth 116 | def change_managed_user_password_route(): 117 | """Allows a logged-in managed user to change their own password.""" 118 | user_data = getattr(g, 'user', None) 119 | if not user_data or user_data.get('service_type') != 'plex_managed': 120 | return jsonify({"error": "Not authorized or not a managed user"}), 403 121 | 122 | data = request.get_json() 123 | current_password = data.get('current_password') 124 | new_password = data.get('new_password') 125 | 126 | if not current_password or not new_password: 127 | return jsonify({"error": "Current password and new password are required"}), 400 128 | 129 | if len(new_password) < 6: 130 | return jsonify({"error": "New password must be at least 6 characters long"}), 400 131 | 132 | username = user_data['internal_username'] 133 | 134 | valid_current_password, message, _ = auth_manager.db.verify_managed_user_password(username, current_password) 135 | if not valid_current_password: 136 | return jsonify({"error": message or "Incorrect current password"}), 400 137 | 138 | success, message = auth_manager.db.update_managed_user_password(username, new_password) 139 | if success: 140 | return jsonify({"message": "Password updated successfully"}), 200 141 | else: 142 | return jsonify({"error": message or "Failed to update password"}), 500 143 | -------------------------------------------------------------------------------- /utils/auth/passkey_routes.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request, jsonify, session, make_response, current_app 2 | from datetime import datetime, timedelta 3 | import logging 4 | import json 5 | 6 | from .manager import auth_manager 7 | from utils.settings import settings 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | passkey_bp = Blueprint('passkey_auth', __name__, url_prefix='/api/auth/passkey') 12 | 13 | def _is_passkey_auth_enabled(): 14 | """Check if passkey authentication is enabled in settings.""" 15 | return settings.get('auth', {}).get('passkey_enabled', False) 16 | 17 | @passkey_bp.before_request 18 | def check_passkey_enabled_for_management(): 19 | """ 20 | Decorator to ensure passkey auth is enabled before accessing passkey management routes. 21 | Login option routes should be available even if passkeys are disabled globally, 22 | as the user might still have a passkey registered from when it was enabled. 23 | The login verification will ultimately fail if the feature is off. 24 | """ 25 | if request.endpoint and request.endpoint not in ['passkey_auth.passkey_login_options', 'passkey_auth.passkey_login_verify', 'passkey_auth.passkey_status']: 26 | if not _is_passkey_auth_enabled(): 27 | logger.warning(f"Passkey route {request.endpoint} accessed but feature not enabled in settings.") 28 | return jsonify({"error": "Passkey authentication is not enabled."}), 403 29 | 30 | @passkey_bp.route('/status', methods=['GET']) 31 | def passkey_status(): 32 | """Returns the status of passkey authentication enablement.""" 33 | return jsonify({"passkeys_enabled": _is_passkey_auth_enabled()}), 200 34 | 35 | @passkey_bp.route('/register-options', methods=['POST']) 36 | @auth_manager.require_auth 37 | def passkey_register_options(): 38 | """Generate options for passkey registration.""" 39 | user_data = session.get('user_data') 40 | if not user_data: 41 | token = request.cookies.get('auth_token') 42 | user_data = auth_manager.verify_auth(token) 43 | 44 | if not user_data or user_data.get('service_type') != 'local': 45 | logger.warning(f"Passkey registration options attempt for non-local user: {user_data.get('username') if user_data else 'Unknown'}") 46 | return jsonify({"error": "Passkeys can only be registered for local accounts."}), 403 47 | 48 | username = user_data['username'] 49 | 50 | options_json, error_msg = auth_manager.generate_registration_options(username) 51 | if error_msg: 52 | return jsonify({"error": error_msg}), 400 53 | if not options_json: 54 | return jsonify({"error": "Failed to generate registration options."}), 500 55 | 56 | return jsonify(json.loads(options_json)), 200 57 | 58 | @passkey_bp.route('/register-verify', methods=['POST']) 59 | @auth_manager.require_auth 60 | def passkey_register_verify(): 61 | """Verify the passkey registration response.""" 62 | user_data = session.get('user_data') 63 | if not user_data: 64 | token = request.cookies.get('auth_token') 65 | user_data = auth_manager.verify_auth(token) 66 | 67 | if not user_data or user_data.get('service_type') != 'local': 68 | return jsonify({"error": "Passkeys can only be registered for local accounts."}), 403 69 | 70 | username = user_data['username'] 71 | registration_data = request.get_json() 72 | 73 | if not registration_data: 74 | return jsonify({"error": "Registration response data is required."}), 400 75 | 76 | registration_response_json = json.dumps(registration_data) 77 | passkey_name = registration_data.get('name') 78 | 79 | success, message = auth_manager.verify_registration_response(username, registration_response_json, passkey_name=passkey_name) 80 | if success: 81 | return jsonify({"message": message}), 200 82 | else: 83 | return jsonify({"error": message}), 400 84 | 85 | @passkey_bp.route('/login-options', methods=['POST']) 86 | def passkey_login_options(): 87 | """Generate options for passkey login.""" 88 | data = request.get_json(silent=True) or {} 89 | username = data.get('username') 90 | 91 | options_json, error_msg = auth_manager.generate_login_options(username) 92 | if error_msg: 93 | return jsonify({"error": error_msg}), 400 94 | if not options_json: 95 | return jsonify({"error": "Failed to generate login options."}), 500 96 | 97 | return jsonify(json.loads(options_json)), 200 98 | 99 | @passkey_bp.route('/login-verify', methods=['POST']) 100 | def passkey_login_verify(): 101 | """Verify the passkey login response and create a session.""" 102 | login_response_json = request.get_data(as_text=True) 103 | if not login_response_json: 104 | return jsonify({"error": "Login response data is required."}), 400 105 | 106 | success, message, session_token = auth_manager.verify_login_response(login_response_json) 107 | 108 | if success and session_token: 109 | resp = make_response(jsonify({"message": message}), 200) 110 | try: 111 | session_lifetime_seconds = int(settings.get('auth', {}).get('session_lifetime', 86400)) 112 | except (ValueError, TypeError): 113 | session_lifetime_seconds = 86400 114 | 115 | expires = datetime.now() + timedelta(seconds=session_lifetime_seconds) 116 | resp.set_cookie( 117 | 'auth_token', 118 | session_token, 119 | expires=expires, 120 | max_age=session_lifetime_seconds, 121 | httponly=True, 122 | secure=request.is_secure, 123 | samesite='Lax' 124 | ) 125 | logger.info(f"Passkey login successful, session cookie set for user identified by passkey.") 126 | return resp 127 | else: 128 | logger.warning(f"Passkey login verification failed: {message}") 129 | return jsonify({"error": message or "Passkey login failed."}), 401 130 | 131 | @passkey_bp.route('/list', methods=['GET']) 132 | @auth_manager.require_auth 133 | def list_user_passkeys_route(): 134 | """List passkeys for the currently authenticated local user.""" 135 | 136 | user_data = session.get('user_data') 137 | if not user_data: 138 | token = request.cookies.get('auth_token') 139 | user_data = auth_manager.verify_auth(token) 140 | 141 | if not user_data or user_data.get('service_type') != 'local': 142 | return jsonify({"error": "Passkeys can only be listed for local accounts."}), 403 143 | 144 | username = user_data['username'] 145 | passkeys = auth_manager.list_user_passkeys(username) 146 | return jsonify(passkeys), 200 147 | 148 | @passkey_bp.route('/remove', methods=['POST']) 149 | @auth_manager.require_auth 150 | def remove_user_passkey_route(): 151 | """Remove a passkey for the currently authenticated local user.""" 152 | user_data = session.get('user_data') 153 | if not user_data: 154 | token = request.cookies.get('auth_token') 155 | user_data = auth_manager.verify_auth(token) 156 | 157 | if not user_data or user_data.get('service_type') != 'local': 158 | return jsonify({"error": "Passkeys can only be removed for local accounts."}), 403 159 | 160 | username = user_data['username'] 161 | data = request.get_json() 162 | if not data or 'credential_id' not in data: 163 | return jsonify({"error": "Credential ID is required."}), 400 164 | 165 | credential_id_b64_str = data['credential_id'] 166 | success, message = auth_manager.remove_user_passkey(username, credential_id_b64_str) 167 | 168 | if success: 169 | return jsonify({"message": message}), 200 170 | else: 171 | return jsonify({"error": message}), 400 172 | 173 | def register_passkey_routes(app): 174 | """Function to register this blueprint with the Flask app.""" 175 | app.register_blueprint(passkey_bp) 176 | -------------------------------------------------------------------------------- /utils/fetch_movie_links.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import requests 4 | from plexapi.server import PlexServer 5 | from utils.settings import settings 6 | import logging 7 | from functools import lru_cache 8 | from utils.tmdb_service import tmdb_service 9 | from threading import Lock 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | # Add cache for TMDb IDs 14 | TMDB_ID_CACHE = {} 15 | TMDB_ID_CACHE_LOCK = Lock() 16 | BATCH_SIZE = 20 # Process movies in batches 17 | 18 | def initialize_services(): 19 | """Initialize services based on settings""" 20 | global PLEX_AVAILABLE, JELLYFIN_AVAILABLE, plex, PLEX_MOVIE_LIBRARIES 21 | 22 | # Get settings 23 | plex_settings = settings.get('plex', {}) 24 | jellyfin_settings = settings.get('jellyfin', {}) 25 | 26 | # First check ENV variables for Plex 27 | plex_url = os.getenv('PLEX_URL') 28 | plex_token = os.getenv('PLEX_TOKEN') 29 | plex_libraries = os.getenv('PLEX_MOVIE_LIBRARIES') 30 | 31 | # If not in ENV, check settings 32 | if not all([plex_url, plex_token, plex_libraries]): 33 | plex_url = plex_settings.get('url') 34 | plex_token = plex_settings.get('token') 35 | plex_libraries = plex_settings.get('movie_libraries') 36 | 37 | # Set PLEX_AVAILABLE based on having all required config 38 | PLEX_AVAILABLE = bool(plex_url and plex_token and plex_libraries) 39 | 40 | JELLYFIN_AVAILABLE = ( 41 | bool(jellyfin_settings.get('enabled')) or 42 | all([ 43 | os.getenv('JELLYFIN_URL'), 44 | os.getenv('JELLYFIN_API_KEY'), 45 | os.getenv('JELLYFIN_USER_ID') 46 | ]) 47 | ) 48 | 49 | if PLEX_AVAILABLE: 50 | try: 51 | # Convert libraries to list if it's a string 52 | if isinstance(plex_libraries, str): 53 | PLEX_MOVIE_LIBRARIES = plex_libraries.split(',') 54 | else: 55 | PLEX_MOVIE_LIBRARIES = plex_libraries 56 | 57 | # Clean up library names 58 | PLEX_MOVIE_LIBRARIES = [lib.strip() for lib in PLEX_MOVIE_LIBRARIES if lib.strip()] 59 | 60 | # Initialize Plex 61 | plex = PlexServer(plex_url, plex_token) 62 | logger.info(f"Plex initialized with URL: {plex_url}, Libraries: [REDACTED]") 63 | except Exception as e: 64 | logger.error(f"Error initializing Plex: {e}") 65 | PLEX_AVAILABLE = False 66 | plex = None 67 | PLEX_MOVIE_LIBRARIES = [] 68 | else: 69 | plex = None 70 | PLEX_MOVIE_LIBRARIES = [] 71 | 72 | # Initialize global variables 73 | PLEX_AVAILABLE = False 74 | JELLYFIN_AVAILABLE = False 75 | plex = None 76 | PLEX_MOVIE_LIBRARIES = [] 77 | 78 | # Initialize services 79 | initialize_services() 80 | 81 | @lru_cache(maxsize=1000) 82 | def get_tmdb_id_from_plex(movie_id, title): 83 | """Cached TMDb ID lookup from Plex""" 84 | cache_key = f"{movie_id}:{title}" 85 | 86 | with TMDB_ID_CACHE_LOCK: 87 | if cache_key in TMDB_ID_CACHE: 88 | return TMDB_ID_CACHE[cache_key] 89 | 90 | if not PLEX_AVAILABLE or not plex: 91 | return None 92 | 93 | try: 94 | for library_name in PLEX_MOVIE_LIBRARIES: 95 | try: 96 | library = plex.library.section(library_name.strip()) 97 | # Try by ID first 98 | try: 99 | movie = library.fetchItem(int(movie_id)) 100 | for guid in movie.guids: 101 | if 'tmdb://' in guid.id: 102 | tmdb_id = guid.id.split('//')[1] 103 | TMDB_ID_CACHE[cache_key] = tmdb_id 104 | return tmdb_id 105 | except Exception: 106 | pass 107 | 108 | # Fallback to title search 109 | movie = library.get(title) 110 | for guid in movie.guids: 111 | if 'tmdb://' in guid.id: 112 | tmdb_id = guid.id.split('//')[1] 113 | TMDB_ID_CACHE[cache_key] = tmdb_id 114 | return tmdb_id 115 | except Exception as e: 116 | logger.debug(f"Error searching in library {library_name}: {e}") 117 | continue 118 | except Exception as e: 119 | logger.error(f"Error getting TMDB ID from Plex: {e}") 120 | 121 | TMDB_ID_CACHE[cache_key] = None 122 | return None 123 | 124 | def get_tmdb_id_from_jellyfin(movie_data): 125 | """Get TMDb ID from Jellyfin provider IDs""" 126 | provider_ids = movie_data.get('ProviderIds', {}) 127 | return provider_ids.get('Tmdb') 128 | 129 | @lru_cache(maxsize=1000) 130 | def fetch_movie_links_from_tmdb_id(tmdb_id): 131 | """Cached version of link fetching with optimized Trakt request""" 132 | if not tmdb_id: 133 | return None, None, None 134 | 135 | tmdb_url = f"https://www.themoviedb.org/movie/{tmdb_id}" 136 | 137 | try: 138 | # Get TMDB details and Trakt info in parallel 139 | movie = tmdb_service.get_movie_details(tmdb_id) 140 | if not movie: 141 | return tmdb_url, None, None 142 | 143 | imdb_id = movie.get('imdb_id') 144 | imdb_url = f"https://www.imdb.com/title/{imdb_id}" if imdb_id else None 145 | 146 | # Optimized Trakt lookup 147 | trakt_url = None 148 | trakt_client_id = os.getenv('TRAKT_CLIENT_ID', '') 149 | if trakt_client_id: 150 | try: 151 | response = requests.get( 152 | f"https://api.trakt.tv/search/tmdb/{tmdb_id}?type=movie", 153 | headers={ 154 | 'Content-Type': 'application/json', 155 | 'trakt-api-version': '2', 156 | 'trakt-api-key': trakt_client_id 157 | }, 158 | timeout=2 # Add timeout 159 | ) 160 | if response.ok and response.json(): 161 | trakt_id = response.json()[0]['movie']['ids']['slug'] 162 | trakt_url = f"https://trakt.tv/movies/{trakt_id}" 163 | except (requests.RequestException, KeyError, IndexError) as e: 164 | logger.debug(f"Trakt lookup failed for TMDb ID {tmdb_id}: {e}") 165 | 166 | return tmdb_url, trakt_url, imdb_url 167 | except Exception as e: 168 | logger.error(f"Error fetching movie links for TMDb ID {tmdb_id}: {e}") 169 | return tmdb_url, None, None 170 | 171 | def fetch_movie_links(movie_data, service): 172 | """Optimized function to fetch movie links using centralized TMDB service""" 173 | # Use TMDb ID if available 174 | tmdb_id = movie_data.get('tmdb_id') 175 | 176 | if not tmdb_id: 177 | # Only lookup if necessary 178 | if service == 'plex' and PLEX_AVAILABLE: 179 | tmdb_id = get_tmdb_id_from_plex(movie_data.get('id'), movie_data.get('title')) 180 | elif service == 'jellyfin' and JELLYFIN_AVAILABLE: 181 | tmdb_id = get_tmdb_id_from_jellyfin(movie_data) 182 | 183 | if tmdb_id: 184 | logger.debug(f"Found TMDb ID {tmdb_id} for movie {movie_data.get('title', '')}") 185 | return tmdb_service.get_movie_links(tmdb_id) 186 | 187 | logger.debug(f"No TMDb ID found for movie {movie_data.get('title', '')}") 188 | return None, None, None 189 | -------------------------------------------------------------------------------- /utils/jellyseerr_service.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | import logging 4 | import json 5 | from dotenv import load_dotenv 6 | from utils.settings import settings 7 | from utils.tmdb_service import tmdb_service 8 | 9 | load_dotenv() 10 | logger = logging.getLogger(__name__) 11 | 12 | # Global state 13 | JELLYSEERR_INITIALIZED = False 14 | JELLYSEERR_URL = None 15 | JELLYSEERR_API_KEY = None 16 | 17 | def initialize_jellyseerr(): 18 | """Initialize or reinitialize Jellyseerr service""" 19 | global JELLYSEERR_INITIALIZED, JELLYSEERR_URL, JELLYSEERR_API_KEY 20 | 21 | # Get settings 22 | jellyseerr_settings = settings.get('jellyseerr', {}) 23 | enabled = jellyseerr_settings.get('enabled', False) 24 | 25 | # Get values from ENV or settings 26 | JELLYSEERR_URL = os.getenv('JELLYSEERR_URL') or jellyseerr_settings.get('url', '').strip() 27 | JELLYSEERR_API_KEY = os.getenv('JELLYSEERR_API_KEY') or jellyseerr_settings.get('api_key', '').strip() 28 | 29 | # Check if service should be enabled 30 | is_env_configured = bool(os.getenv('JELLYSEERR_URL') and os.getenv('JELLYSEERR_API_KEY')) 31 | is_settings_configured = bool(enabled and JELLYSEERR_URL and JELLYSEERR_API_KEY) 32 | 33 | if is_env_configured or is_settings_configured: 34 | try: 35 | JELLYSEERR_INITIALIZED = True 36 | 37 | # Save state to file 38 | state_file = '/app/data/jellyseerr_state.json' 39 | os.makedirs(os.path.dirname(state_file), exist_ok=True) 40 | state = { 41 | 'initialized': True, 42 | 'url': JELLYSEERR_URL, 43 | 'api_key': bool(JELLYSEERR_API_KEY) 44 | } 45 | with open(state_file, 'w') as f: 46 | json.dump(state, f) 47 | return True 48 | except Exception as e: 49 | logger.error(f"Failed to save Jellyseerr state: {e}") 50 | JELLYSEERR_INITIALIZED = False 51 | return False 52 | 53 | JELLYSEERR_INITIALIZED = False 54 | 55 | # Clean up state file if it exists 56 | try: 57 | state_file = '/app/data/jellyseerr_state.json' 58 | if os.path.exists(state_file): 59 | os.remove(state_file) 60 | except Exception as e: 61 | logger.error(f"Failed to clean up Jellyseerr state file: {e}") 62 | 63 | return False 64 | 65 | # Initialize right away 66 | initialize_jellyseerr() 67 | 68 | JELLYSEERR_HEADERS = {} 69 | 70 | def update_headers(): 71 | """Update headers with current API key""" 72 | global JELLYSEERR_HEADERS 73 | if JELLYSEERR_API_KEY: 74 | JELLYSEERR_HEADERS = { 75 | 'X-Api-Key': JELLYSEERR_API_KEY, 76 | 'Content-Type': 'application/json' 77 | } 78 | 79 | def get_jellyseerr_csrf_token(): 80 | """Gets CSRF token from Jellyseerr.""" 81 | if not JELLYSEERR_INITIALIZED: 82 | logger.warning("Cannot get CSRF token: Jellyseerr not initialized") 83 | return None 84 | 85 | try: 86 | update_headers() 87 | response = requests.get(f"{JELLYSEERR_URL}/auth/me", headers=JELLYSEERR_HEADERS) 88 | response.raise_for_status() 89 | 90 | csrf_token = response.cookies.get('XSRF-TOKEN') 91 | if csrf_token: 92 | return csrf_token 93 | else: 94 | logger.info("CSRF token not found in response cookies. CSRF might be disabled.") 95 | return None 96 | except requests.RequestException as e: 97 | logger.error(f"Error getting CSRF token: {e}") 98 | return None 99 | 100 | def request_movie(movie_id, csrf_token=None): 101 | """Sends a request to Jellyseerr to add a movie to the request list.""" 102 | if not JELLYSEERR_INITIALIZED: 103 | logger.warning("Cannot request movie: Jellyseerr not initialized") 104 | return None 105 | 106 | try: 107 | update_headers() 108 | endpoint = f"{JELLYSEERR_URL}/api/v1/request" 109 | headers = JELLYSEERR_HEADERS.copy() 110 | if csrf_token: 111 | headers['X-CSRF-Token'] = csrf_token 112 | 113 | data = { 114 | "mediaId": int(movie_id), 115 | "mediaType": "movie" 116 | } 117 | 118 | response = requests.post(endpoint, headers=headers, json=data) 119 | response.raise_for_status() 120 | return response.json() 121 | 122 | except requests.RequestException as e: 123 | if hasattr(e.response, 'text'): 124 | logger.error(f"Error from Jellyseerr: {e.response.text}") 125 | logger.error(f"Request error: {str(e)}") 126 | return None 127 | except Exception as e: 128 | logger.error(f"Unexpected error in request_movie: {str(e)}") 129 | return None 130 | 131 | def get_media_status(tmdb_id): 132 | """Get media status from Jellyseerr for a specific TMDb ID.""" 133 | if not JELLYSEERR_INITIALIZED: 134 | logger.warning("Cannot check media: Jellyseerr not initialized") 135 | return None 136 | 137 | update_headers() 138 | endpoint = f"{JELLYSEERR_URL}/api/v1/movie/{tmdb_id}" 139 | 140 | try: 141 | response = requests.get(endpoint, headers=JELLYSEERR_HEADERS) 142 | response.raise_for_status() 143 | data = response.json() 144 | return data 145 | except requests.RequestException as e: 146 | logger.error(f"Error checking media status: {e}") 147 | return None 148 | 149 | def update_configuration(url, api_key): 150 | """Update service configuration""" 151 | global JELLYSEERR_URL, JELLYSEERR_API_KEY, JELLYSEERR_INITIALIZED 152 | JELLYSEERR_URL = url 153 | JELLYSEERR_API_KEY = api_key 154 | update_headers() 155 | JELLYSEERR_INITIALIZED = bool(url and api_key) 156 | return initialize_jellyseerr() 157 | -------------------------------------------------------------------------------- /utils/ombi_service.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | import logging 4 | import json 5 | from dotenv import load_dotenv 6 | from utils.settings import settings 7 | from utils.tmdb_service import tmdb_service 8 | 9 | load_dotenv() 10 | logger = logging.getLogger(__name__) 11 | 12 | # Global state 13 | OMBI_INITIALIZED = False 14 | OMBI_URL = None 15 | OMBI_API_KEY = None 16 | 17 | def write_debug(message): 18 | """Write debug message to a file""" 19 | try: 20 | with open('/app/data/debug.log', 'a') as f: 21 | f.write(f"{message}\n") 22 | except Exception as e: 23 | print(f"Error writing debug: {e}") 24 | 25 | def initialize_ombi(): 26 | """Initialize or reinitialize Ombi service""" 27 | global OMBI_INITIALIZED, OMBI_URL, OMBI_API_KEY 28 | write_debug("\n=== initialize_ombi called ===") 29 | 30 | # First capture current state for logging 31 | previous_state = { 32 | 'initialized': OMBI_INITIALIZED, 33 | 'has_url': bool(OMBI_URL), 34 | 'has_key': bool(OMBI_API_KEY) 35 | } 36 | write_debug(f"Previous state: {previous_state}") 37 | 38 | # Get settings 39 | ombi_settings = settings.get('ombi', {}) 40 | enabled = ombi_settings.get('enabled', False) 41 | write_debug(f"Settings: {ombi_settings}") 42 | write_debug(f"Enabled: {enabled}") 43 | 44 | # Get values from ENV or settings 45 | OMBI_URL = os.getenv('OMBI_URL') or ombi_settings.get('url', '').strip() 46 | OMBI_API_KEY = os.getenv('OMBI_API_KEY') or ombi_settings.get('api_key', '').strip() 47 | 48 | # Check if service should be enabled 49 | is_env_configured = bool(os.getenv('OMBI_URL') and os.getenv('OMBI_API_KEY')) 50 | is_settings_configured = bool(enabled and OMBI_URL and OMBI_API_KEY) 51 | write_debug(f"ENV configured: {is_env_configured}") 52 | write_debug(f"Settings configured: {is_settings_configured}") 53 | 54 | if is_env_configured or is_settings_configured: 55 | try: 56 | OMBI_INITIALIZED = True 57 | 58 | # Save state to file 59 | state_file = '/app/data/ombi_state.json' 60 | os.makedirs(os.path.dirname(state_file), exist_ok=True) 61 | state = { 62 | 'initialized': True, 63 | 'url': OMBI_URL, 64 | 'api_key': bool(OMBI_API_KEY) 65 | } 66 | with open(state_file, 'w') as f: 67 | json.dump(state, f) 68 | 69 | write_debug(f"State file written: {state}") 70 | write_debug("Ombi service initialized successfully") 71 | logger.info("Ombi service initialized successfully") 72 | return True 73 | except Exception as e: 74 | write_debug(f"Failed to save Ombi state: {e}") 75 | logger.error(f"Failed to save Ombi state: {e}") 76 | OMBI_INITIALIZED = False 77 | return False 78 | 79 | OMBI_INITIALIZED = False 80 | 81 | # Clean up state file if it exists 82 | try: 83 | state_file = '/app/data/ombi_state.json' 84 | if os.path.exists(state_file): 85 | os.remove(state_file) 86 | write_debug("State file removed") 87 | except Exception as e: 88 | write_debug(f"Failed to clean up Ombi state file: {e}") 89 | 90 | write_debug("Ombi service not initialized - missing configuration or disabled") 91 | logger.info("Ombi service not initialized - missing configuration or disabled") 92 | return False 93 | 94 | # Initialize right away 95 | initialize_ombi() 96 | 97 | OMBI_HEADERS = {} 98 | 99 | def update_headers(): 100 | """Update headers with current API key""" 101 | global OMBI_HEADERS 102 | if OMBI_API_KEY: 103 | OMBI_HEADERS = { 104 | 'ApiKey': OMBI_API_KEY, # Note: Ombi uses 'ApiKey' instead of 'X-Api-Key' 105 | 'Content-Type': 'application/json' 106 | } 107 | 108 | def get_ombi_csrf_token(): 109 | """Gets CSRF token from Ombi if needed.""" 110 | if not OMBI_INITIALIZED: 111 | logger.warning("Cannot get CSRF token: Ombi not initialized") 112 | return None 113 | 114 | try: 115 | update_headers() 116 | response = requests.get(f"{OMBI_URL}/api/v1/Settings/about", headers=OMBI_HEADERS) 117 | response.raise_for_status() 118 | 119 | csrf_token = response.cookies.get('XSRF-TOKEN') 120 | if csrf_token: 121 | return csrf_token 122 | else: 123 | logger.info("CSRF token not found in response cookies. CSRF might be disabled.") 124 | return None 125 | except requests.RequestException as e: 126 | logger.error(f"Error getting CSRF token: {e}") 127 | return None 128 | 129 | def request_movie(movie_id, csrf_token=None): 130 | """Sends a request to Ombi to add a movie to the request list.""" 131 | if not OMBI_INITIALIZED: 132 | logger.warning("Cannot request movie: Ombi not initialized") 133 | return None 134 | 135 | try: 136 | update_headers() 137 | endpoint = f"{OMBI_URL}/api/v1/Request/movie" 138 | headers = OMBI_HEADERS.copy() 139 | if csrf_token: 140 | headers['X-CSRF-Token'] = csrf_token 141 | 142 | data = { 143 | "theMovieDbId": int(movie_id), 144 | "languageCode": "string", # Default to system language 145 | "is4KRequest": False, 146 | "rootFolderOverride": 0, 147 | "qualityPathOverride": 0 148 | } 149 | 150 | logger.info(f"Making request to Ombi - URL: {endpoint}") 151 | logger.debug(f"Request data: {data}") 152 | 153 | response = requests.post(endpoint, headers=headers, json=data) 154 | 155 | # Log the response for debugging 156 | logger.debug(f"Response status code: {response.status_code}") 157 | logger.debug(f"Response content: {response.text}") 158 | 159 | response.raise_for_status() 160 | return response.json() 161 | 162 | except requests.RequestException as e: 163 | if hasattr(e.response, 'text'): 164 | logger.error(f"Error from Ombi: {e.response.text}") 165 | logger.error(f"Request error: {str(e)}") 166 | return None 167 | except Exception as e: 168 | logger.error(f"Unexpected error in request_movie: {str(e)}") 169 | return None 170 | 171 | def get_media_status(tmdb_id): 172 | """Get media status from Ombi for a specific TMDb ID.""" 173 | if not OMBI_INITIALIZED: 174 | logger.warning("Cannot check media: Ombi not initialized") 175 | return None 176 | 177 | update_headers() 178 | 179 | try: 180 | # First try to get the movie request status 181 | response = requests.get( 182 | f"{OMBI_URL}/api/v1/Request/movie", 183 | headers=OMBI_HEADERS 184 | ) 185 | response.raise_for_status() 186 | all_requests = response.json() 187 | 188 | # Find the request matching our tmdb_id 189 | for request in all_requests: 190 | if request.get('theMovieDbId') == int(tmdb_id): 191 | # Convert Ombi status to match Overseerr/Jellyseerr format 192 | status = 4 if request.get('approved') else 3 # 4=approved, 3=requested 193 | if request.get('available'): 194 | status = 5 # available 195 | if request.get('denied'): 196 | status = 2 # denied 197 | 198 | return { 199 | "mediaInfo": { 200 | "status": status, 201 | "requested": True, 202 | "available": request.get('available', False), 203 | "approved": request.get('approved', False) 204 | } 205 | } 206 | 207 | # If no request found, return standard format with no request 208 | return { 209 | "mediaInfo": { 210 | "status": 1, # not requested 211 | "requested": False 212 | } 213 | } 214 | 215 | except requests.RequestException as e: 216 | logger.error(f"Error checking media status: {e}") 217 | if hasattr(e.response, 'text'): 218 | logger.error(f"Response content: {e.response.text}") 219 | return None 220 | except Exception as e: 221 | logger.error(f"Unexpected error in get_media_status: {str(e)}") 222 | return None 223 | 224 | def get_user_requests(): 225 | """Get all movie requests for the current user.""" 226 | if not OMBI_INITIALIZED: 227 | logger.warning("Cannot get requests: Ombi not initialized") 228 | return None 229 | 230 | update_headers() 231 | try: 232 | response = requests.get(f"{OMBI_URL}/api/v1/Request/movie", headers=OMBI_HEADERS) 233 | response.raise_for_status() 234 | return response.json() 235 | except requests.RequestException as e: 236 | logger.error(f"Error getting user requests: {e}") 237 | return None 238 | 239 | def update_configuration(url, api_key): 240 | """Update service configuration""" 241 | global OMBI_URL, OMBI_API_KEY, OMBI_INITIALIZED 242 | OMBI_URL = url 243 | OMBI_API_KEY = api_key 244 | update_headers() 245 | OMBI_INITIALIZED = bool(url and api_key) 246 | return initialize_ombi() 247 | -------------------------------------------------------------------------------- /utils/overseerr_service.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | import logging 4 | import json 5 | from dotenv import load_dotenv 6 | from utils.settings import settings 7 | from utils.tmdb_service import tmdb_service 8 | 9 | load_dotenv() # Load environment variables from .env 10 | logger = logging.getLogger(__name__) 11 | 12 | # Global state 13 | OVERSEERR_INITIALIZED = False 14 | OVERSEERR_URL = None 15 | OVERSEERR_API_KEY = None 16 | 17 | def initialize_overseerr(): 18 | """Initialize or reinitialize Overseerr service""" 19 | global OVERSEERR_INITIALIZED, OVERSEERR_URL, OVERSEERR_API_KEY 20 | 21 | # First capture current state for logging 22 | previous_state = { 23 | 'initialized': OVERSEERR_INITIALIZED, 24 | 'has_url': bool(OVERSEERR_URL), 25 | 'has_key': bool(OVERSEERR_API_KEY) 26 | } 27 | 28 | # Get settings 29 | overseerr_settings = settings.get('overseerr', {}) 30 | enabled = overseerr_settings.get('enabled', False) 31 | 32 | # Get values from ENV or settings 33 | OVERSEERR_URL = os.getenv('OVERSEERR_URL') or overseerr_settings.get('url', '').strip() 34 | OVERSEERR_API_KEY = os.getenv('OVERSEERR_API_KEY') or overseerr_settings.get('api_key', '').strip() 35 | 36 | # Check if service should be enabled 37 | is_env_configured = bool(os.getenv('OVERSEERR_URL') and os.getenv('OVERSEERR_API_KEY')) 38 | is_settings_configured = bool(enabled and OVERSEERR_URL and OVERSEERR_API_KEY) 39 | 40 | if is_env_configured or is_settings_configured: 41 | try: 42 | OVERSEERR_INITIALIZED = True 43 | 44 | # Save state to file 45 | state_file = '/app/data/overseerr_state.json' 46 | os.makedirs(os.path.dirname(state_file), exist_ok=True) 47 | with open(state_file, 'w') as f: 48 | json.dump({ 49 | 'initialized': True, 50 | 'url': OVERSEERR_URL, 51 | 'api_key': bool(OVERSEERR_API_KEY) 52 | }, f) 53 | 54 | logger.info("Overseerr service initialized successfully") 55 | logger.debug(f"Initialization details - URL: {OVERSEERR_URL}, API Key exists: {bool(OVERSEERR_API_KEY)}") 56 | logger.debug(f"Previous state: {previous_state}") 57 | return True 58 | except Exception as e: 59 | logger.error(f"Failed to save Overseerr state: {e}") 60 | OVERSEERR_INITIALIZED = False 61 | return False 62 | 63 | OVERSEERR_INITIALIZED = False 64 | 65 | # Clean up state file if it exists 66 | try: 67 | state_file = '/app/data/overseerr_state.json' 68 | if os.path.exists(state_file): 69 | os.remove(state_file) 70 | except Exception as e: 71 | logger.error(f"Failed to clean up Overseerr state file: {e}") 72 | 73 | logger.info("Overseerr service not initialized - missing configuration or disabled") 74 | return False 75 | 76 | # Initialize right away 77 | initialize_overseerr() 78 | 79 | OVERSEERR_HEADERS = {} 80 | 81 | def update_headers(): 82 | """Update headers with current API key""" 83 | global OVERSEERR_HEADERS 84 | if OVERSEERR_API_KEY: 85 | OVERSEERR_HEADERS = { 86 | 'X-Api-Key': OVERSEERR_API_KEY, 87 | 'Content-Type': 'application/json' 88 | } 89 | 90 | def get_overseerr_csrf_token(): 91 | """Gets CSRF token from Overseerr.""" 92 | if not OVERSEERR_INITIALIZED: 93 | logger.warning("Cannot get CSRF token: Overseerr not initialized") 94 | return None 95 | 96 | try: 97 | update_headers() 98 | response = requests.get(f"{OVERSEERR_URL}/auth/me", headers=OVERSEERR_HEADERS) 99 | response.raise_for_status() 100 | 101 | csrf_token = response.cookies.get('XSRF-TOKEN') 102 | if csrf_token: 103 | return csrf_token 104 | else: 105 | logger.info("CSRF token not found in response cookies. CSRF might be disabled.") 106 | return None 107 | except requests.RequestException as e: 108 | logger.error(f"Error getting CSRF token: {e}") 109 | return None 110 | 111 | def request_movie(movie_id, csrf_token=None): 112 | """Sends a request to Overseerr to add a movie to the request list.""" 113 | if not OVERSEERR_INITIALIZED: 114 | logger.warning("Cannot request movie: Overseerr not initialized") 115 | return None 116 | 117 | update_headers() 118 | endpoint = f"{OVERSEERR_URL}/api/v1/request" 119 | headers = OVERSEERR_HEADERS.copy() 120 | if csrf_token: 121 | headers['X-CSRF-Token'] = csrf_token 122 | 123 | data = { 124 | "mediaId": int(movie_id), 125 | "mediaType": "movie" 126 | } 127 | 128 | try: 129 | response = requests.post(endpoint, headers=headers, json=data) 130 | response.raise_for_status() 131 | return response.json() 132 | except requests.RequestException as e: 133 | if csrf_token and 'CSRF' in str(e): 134 | logger.warning("CSRF token failed, retrying without CSRF") 135 | return request_movie(movie_id) # Retry without CSRF token 136 | logger.error(f"Error requesting movie via Overseerr: {e}") 137 | if hasattr(e.response, 'text'): 138 | logger.error(f"Response content: {e.response.text}") 139 | return None 140 | 141 | def fetch_all_movies(): 142 | """Fetches all movies from Overseerr.""" 143 | if not OVERSEERR_INITIALIZED: 144 | logger.warning("Cannot fetch movies: Overseerr not initialized") 145 | return [] 146 | 147 | update_headers() 148 | endpoint = f"{OVERSEERR_URL}/api/v1/movie" 149 | params = { 150 | 'take': 100, 151 | 'skip': 0 152 | } 153 | all_movies = [] 154 | 155 | try: 156 | while True: 157 | response = requests.get(endpoint, headers=OVERSEERR_HEADERS, params=params) 158 | response.raise_for_status() 159 | data = response.json() 160 | movies = data.get('results', []) 161 | if not movies: 162 | break 163 | all_movies.extend(movies) 164 | if len(movies) < params['take']: 165 | break 166 | params['skip'] += params['take'] 167 | except requests.RequestException as e: 168 | logger.error(f"Error fetching all movies from Overseerr: {e}") 169 | return [] 170 | 171 | return all_movies 172 | 173 | def get_tmdb_api_key(): 174 | """Get the TMDB API key from ENV or settings""" 175 | global TMDB_API_KEY 176 | # Get tmdb key from overseerr section 177 | overseerr_settings = settings.get('overseerr', {}) 178 | TMDB_API_KEY = os.getenv('TMDB_API_KEY') or overseerr_settings.get('tmdb_api_key', '') 179 | return TMDB_API_KEY 180 | 181 | def update_configuration(url, api_key): 182 | """Update service configuration""" 183 | global OVERSEERR_URL, OVERSEERR_API_KEY, OVERSEERR_INITIALIZED 184 | OVERSEERR_URL = url 185 | OVERSEERR_API_KEY = api_key 186 | update_headers() 187 | OVERSEERR_INITIALIZED = bool(url and api_key) 188 | return initialize_overseerr() 189 | 190 | def get_media_status(tmdb_id): 191 | """Get media status from Overseerr for a specific TMDb ID.""" 192 | if not OVERSEERR_INITIALIZED: 193 | logger.warning("Cannot check media: Overseerr not initialized") 194 | return None 195 | 196 | update_headers() 197 | endpoint = f"{OVERSEERR_URL}/api/v1/movie/{tmdb_id}" 198 | 199 | try: 200 | response = requests.get(endpoint, headers=OVERSEERR_HEADERS) 201 | response.raise_for_status() 202 | data = response.json() 203 | return data 204 | except requests.RequestException as e: 205 | logger.error(f"Error checking media status: {e}") 206 | return None 207 | -------------------------------------------------------------------------------- /utils/services_loader.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import sys 3 | 4 | def reload_service(module_name): 5 | """Reload a service module and reinitialize it""" 6 | if module_name in sys.modules: 7 | del sys.modules[module_name] 8 | module = importlib.import_module(module_name) 9 | if hasattr(module, 'initialize_overseerr'): 10 | module.initialize_overseerr() 11 | if hasattr(module, 'initialize_trakt'): 12 | module.initialize_trakt() 13 | return module 14 | -------------------------------------------------------------------------------- /utils/settings/__init__.py: -------------------------------------------------------------------------------- 1 | from .manager import Settings 2 | 3 | settings = Settings() 4 | 5 | __all__ = ['settings'] 6 | -------------------------------------------------------------------------------- /utils/settings/config.py: -------------------------------------------------------------------------------- 1 | DEFAULT_SETTINGS = { 2 | 'plex': { 3 | 'enabled': False, 4 | 'url': '', 5 | 'token': '', 6 | 'movie_libraries': [] 7 | }, 8 | 'jellyfin': { 9 | 'enabled': False, 10 | 'url': '', 11 | 'api_key': '', 12 | 'user_id': '', 13 | 'connect_enabled': False 14 | }, 15 | 'emby': { 16 | 'enabled': False, 17 | 'url': '', 18 | 'api_key': '', 19 | 'user_id': '' 20 | }, 21 | 'clients': { 22 | 'apple_tv': { 23 | 'enabled': False, 24 | 'id': '' 25 | }, 26 | 'tvs': { 27 | 'blacklist': { 28 | 'mac_addresses': [] 29 | }, 30 | 'instances': {} 31 | } 32 | }, 33 | 'features': { 34 | 'use_links': True, 35 | 'use_filter': True, 36 | 'use_watch_button': True, 37 | 'use_next_button': True, 38 | 'mobile_truncation': False, 39 | 'homepage_mode': False, 40 | 'enable_movie_logos': True, 41 | 'load_movie_on_start': False, 42 | 'login_backdrop': { 43 | 'enabled': False 44 | }, 45 | 'timezone': 'UTC', 46 | 'poster_mode': 'default', # 'default' or 'screensaver' 47 | 'screensaver_interval': 300, 48 | 'default_poster_text': '', 49 | 'poster_users': { 50 | 'plex': [], 51 | 'jellyfin': [], 52 | 'emby': [] 53 | }, 54 | 'poster_display': { 55 | 'mode': 'first_active', # 'first_active' or 'preferred_user' 56 | 'preferred_user': {} 57 | } 58 | }, 59 | 'request_services': { 60 | 'default': 'auto', # Values: auto, overseerr, jellyseerr, ombi 61 | 'plex_override': 'auto', # Values: auto, overseerr, jellyseerr, ombi 62 | 'jellyfin_override': 'auto', # Values: auto, jellyseerr, ombi 63 | 'emby_override': 'auto' # Values: auto, jellyseerr, ombi 64 | }, 65 | 'overseerr': { 66 | 'enabled': False, 67 | 'url': '', 68 | 'api_key': '' 69 | }, 70 | 'jellyseerr': { 71 | 'enabled': False, 72 | 'url': '', 73 | 'api_key': '' 74 | }, 75 | 'ombi': { 76 | 'enabled': False, 77 | 'url': '', 78 | 'api_key': '' 79 | }, 80 | 'tmdb': { 81 | 'enabled': False, 82 | 'api_key': '' 83 | }, 84 | 'trakt': { 85 | 'enabled': False, 86 | 'client_id': '', 87 | 'client_secret': '', 88 | 'access_token': '', 89 | 'refresh_token': '' 90 | }, 91 | 'system': { 92 | 'disable_settings': False 93 | } 94 | } 95 | 96 | AUTH_SETTINGS = { 97 | 'auth': { 98 | 'enabled': False, 99 | 'session_lifetime': 86400, 100 | 'passkey_enabled': False, 101 | 'relying_party_id': '', # e.g., 'localhost' or 'yourdomain.com' - Should match the domain users use. 102 | 'relying_party_origin': '' # e.g., 'https://yourdomain.com' or 'http://localhost:4000' - Full origin. 103 | } 104 | } 105 | 106 | ENV_MAPPINGS = { 107 | # Features ENV 108 | 'HOMEPAGE_MODE': ('features', 'homepage_mode', lambda x: x.upper() == 'TRUE'), 109 | 'TZ': ('features', 'timezone', str), 110 | 'DEFAULT_POSTER_TEXT': ('features', 'default_poster_text', str), 111 | 'POSTER_MODE': ('features', 'poster_mode', str), 112 | 'SCREENSAVER_INTERVAL': ('features', 'screensaver_interval', int), 113 | 'PLEX_POSTER_USERS': ('features.poster_users', 'plex', lambda x: [s.strip() for s in x.split(',')]), 114 | 'JELLYFIN_POSTER_USERS': ('features.poster_users', 'jellyfin', lambda x: [s.strip() for s in x.split(',')]), 115 | 'EMBY_POSTER_USERS': ('features.poster_users', 'emby', lambda x: [s.strip() for s in x.split(',')]), 116 | 'POSTER_DISPLAY_MODE': ('features.poster_display', 'mode', str), 117 | 'PREFERRED_POSTER_USER': ('features.poster_display.preferred_user', 'username', str), 118 | 'PREFERRED_POSTER_SERVICE': ('features.poster_display.preferred_user', 'service', str), 119 | 120 | # Plex ENV 121 | 'PLEX_URL': ('plex', 'url', str), 122 | 'PLEX_TOKEN': ('plex', 'token', str), 123 | 'PLEX_MOVIE_LIBRARIES': ('plex', 'movie_libraries', lambda x: [s.strip() for s in x.split(',')]), 124 | 125 | # Jellyfin ENV 126 | 'JELLYFIN_URL': ('jellyfin', 'url', str), 127 | 'JELLYFIN_API_KEY': ('jellyfin', 'api_key', str), 128 | 'JELLYFIN_USER_ID': ('jellyfin', 'user_id', str), 129 | 130 | # Emby ENV 131 | 'EMBY_URL': ('emby', 'url', str), 132 | 'EMBY_API_KEY': ('emby', 'api_key', str), 133 | 'EMBY_USER_ID': ('emby', 'user_id', str), 134 | 135 | # Overseerr/Jellyseerr ENV 136 | 'OVERSEERR_URL': ('overseerr', 'url', str), 137 | 'OVERSEERR_API_KEY': ('overseerr', 'api_key', str), 138 | 'JELLYSEERR_URL': ('jellyseerr', 'url', str), 139 | 'JELLYSEERR_API_KEY': ('jellyseerr', 'api_key', str), 140 | 141 | # Ombi ENV 142 | 'OMBI_URL': ('ombi', 'url', str), 143 | 'OMBI_API_KEY': ('ombi', 'api_key', str), 144 | 145 | # Request Services ENV 146 | 'REQUEST_SERVICE_DEFAULT': ('request_services', 'default', str), 147 | 'REQUEST_SERVICE_PLEX': ('request_services', 'plex_override', str), 148 | 'REQUEST_SERVICE_JELLYFIN': ('request_services', 'jellyfin_override', str), 149 | 'REQUEST_SERVICE_EMBY': ('request_services', 'emby_override', str), 150 | 151 | # Feature flags 152 | 'USE_LINKS': ('features', 'use_links', lambda x: x.upper() == 'TRUE'), 153 | 'USE_FILTER': ('features', 'use_filter', lambda x: x.upper() == 'TRUE'), 154 | 'USE_WATCH_BUTTON': ('features', 'use_watch_button', lambda x: x.upper() == 'TRUE'), 155 | 'USE_NEXT_BUTTON': ('features', 'use_next_button', lambda x: x.upper() == 'TRUE'), 156 | 'ENABLE_MOBILE_TRUNCATION': ('features', 'mobile_truncation', lambda x: x.upper() == 'TRUE'), 157 | 'ENABLE_MOVIE_LOGOS': ('features', 'enable_movie_logos', lambda x: x.upper() == 'TRUE'), 158 | 'LOAD_MOVIE_ON_START': ('features', 'load_movie_on_start', lambda x: x.upper() == 'TRUE'), 159 | 'LOGIN_BACKDROP_ENABLED': ('features.login_backdrop', 'enabled', lambda x: x.upper() == 'TRUE'), 160 | # AppleTV ENV 161 | 'APPLE_TV_ID': ('clients.apple_tv', 'id', str), 162 | 163 | # Dynamic TV Configuration ENV 164 | 'TV_(.+)_TYPE': ('clients.tvs.instances.$1', 'type', str), 165 | 'TV_(.+)_IP': ('clients.tvs.instances.$1', 'ip', str), 166 | 'TV_(.+)_MAC': ('clients.tvs.instances.$1', 'mac', str), 167 | 168 | # TMDB 169 | 'TMDB_API_KEY': ('tmdb', 'api_key', str), 170 | 171 | # Trakt 172 | 'TRAKT_CLIENT_ID': ('trakt', 'client_id', str), 173 | 'TRAKT_CLIENT_SECRET': ('trakt', 'client_secret', str), 174 | 'TRAKT_ACCESS_TOKEN': ('trakt', 'access_token', str), 175 | 'TRAKT_REFRESH_TOKEN': ('trakt', 'refresh_token', str), 176 | 177 | # Settings 178 | 'DISABLE_SETTINGS': ('system', 'disable_settings', lambda x: x.upper() == 'TRUE'), 179 | } 180 | 181 | AUTH_ENV_MAPPINGS = { 182 | 'AUTH_ENABLED': ('auth', 'enabled', lambda x: x.upper() == 'TRUE'), 183 | 'AUTH_SESSION_LIFETIME': ('auth', 'session_lifetime', int), 184 | 'AUTH_PASSKEY_ENABLED': ('auth', 'passkey_enabled', lambda x: x.upper() == 'TRUE'), 185 | 'AUTH_RELYING_PARTY_ID': ('auth', 'relying_party_id', str), 186 | 'AUTH_RELYING_PARTY_ORIGIN': ('auth', 'relying_party_origin', str) 187 | } 188 | -------------------------------------------------------------------------------- /utils/settings/validators.py: -------------------------------------------------------------------------------- 1 | def validate_plex(data): 2 | """Validate Plex settings""" 3 | if data.get('enabled'): 4 | required_fields = ['url', 'token'] 5 | missing_fields = [field for field in required_fields 6 | if not data.get(field)] 7 | 8 | if missing_fields: 9 | raise ValueError(f"Missing required Plex fields: {', '.join(missing_fields)}") 10 | return True 11 | 12 | def validate_jellyfin(data): 13 | """Validate Jellyfin settings""" 14 | # Only validate required fields if service is being enabled 15 | if 'enabled' in data and data['enabled']: 16 | required_fields = ['url', 'api_key', 'user_id'] 17 | missing_fields = [field for field in required_fields 18 | if not (data.get(field) or settings.get('jellyfin', field))] 19 | 20 | if missing_fields: 21 | raise ValueError(f"Missing required Jellyfin fields: {', '.join(missing_fields)}") 22 | return True 23 | 24 | def validate_emby(data): 25 | """Validate Emby settings""" 26 | if data.get('enabled'): 27 | # Only require the fields if using direct auth (not Connect) 28 | if not data.get('connect_enabled'): 29 | required_fields = ['url', 'api_key', 'user_id'] 30 | missing_fields = [field for field in required_fields 31 | if not data.get(field)] 32 | if missing_fields: 33 | raise ValueError(f"Missing required Emby fields: {', '.join(missing_fields)}") 34 | else: 35 | # When using Connect, we only require the URL initially 36 | if not data.get('url'): 37 | raise ValueError("Emby server URL is required") 38 | return True 39 | 40 | def validate_overseerr(data): 41 | if data.get('enabled'): 42 | if not data.get('url') or not data.get('api_key'): 43 | raise ValueError("Overseerr URL and API key are required when enabled") 44 | return True 45 | 46 | def validate_trakt(data): 47 | if data.get('enabled'): 48 | required = ['client_id', 'client_secret', 'access_token', 'refresh_token'] 49 | if not all(data.get(key) for key in required): 50 | raise ValueError("All Trakt credentials are required when enabled") 51 | return True 52 | 53 | def validate_clients(data): 54 | if data.get('apple_tv', {}).get('enabled'): 55 | if not data['apple_tv'].get('id'): 56 | raise ValueError("Apple TV ID is required when enabled") 57 | 58 | # Validate smart TVs 59 | if 'tvs' in data and 'instances' in data['tvs']: 60 | for tv_id, tv_config in data['tvs']['instances'].items(): 61 | if tv_config.get('enabled'): 62 | required_fields = ['ip', 'mac', 'type'] 63 | missing = [f for f in required_fields if not tv_config.get(f)] 64 | if missing: 65 | raise ValueError(f"TV '{tv_id}': Missing required fields: {', '.join(missing)}") 66 | 67 | # Validate TV type 68 | if tv_config['type'] not in ['webos', 'tizen', 'android']: 69 | raise ValueError(f"TV '{tv_id}': Invalid type '{tv_config['type']}'") 70 | return True 71 | 72 | def validate_jellyseerr(data): 73 | """Validate Jellyseerr settings""" 74 | if data.get('enabled'): 75 | if not data.get('url') or not data.get('api_key'): 76 | raise ValueError("Jellyseerr URL and API key are required when enabled") 77 | 78 | # force_use is optional and boolean, no validation needed 79 | if 'force_use' in data and not isinstance(data['force_use'], bool): 80 | raise ValueError("force_use must be a boolean value") 81 | return True 82 | 83 | VALIDATORS = { 84 | 'plex': validate_plex, 85 | 'jellyfin': validate_jellyfin, 86 | 'emby': validate_emby, 87 | 'overseerr': validate_overseerr, 88 | 'trakt': validate_trakt, 89 | 'clients': validate_clients, 90 | 'jellyseerr': validate_jellyseerr 91 | } 92 | -------------------------------------------------------------------------------- /utils/tv/__init__.py: -------------------------------------------------------------------------------- 1 | from .base.tv_factory import TVFactory 2 | -------------------------------------------------------------------------------- /utils/tv/base/__init__.py: -------------------------------------------------------------------------------- 1 | from .tv_base import TVControlBase, TVError, TVConnectionError, TVAppError 2 | from .tv_discovery import TVDiscoveryBase, TVDiscoveryFactory 3 | from .tv_factory import TVFactory 4 | -------------------------------------------------------------------------------- /utils/tv/base/tv_base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | import socket 3 | import logging 4 | import asyncio 5 | from typing import Optional, Dict, Any 6 | from utils.settings import settings 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | class TVControlBase(ABC): 11 | """Base class for TV control implementations""" 12 | 13 | def __init__(self, ip: str = None, mac: str = None): 14 | """Initialize TV control with optional IP and MAC address""" 15 | self.ip = ip 16 | self.mac = mac 17 | self._config = self._load_config() 18 | 19 | if not self.ip: 20 | self.ip = self._config.get('ip') 21 | if not self.mac: 22 | self.mac = self._config.get('mac') 23 | 24 | @property 25 | @abstractmethod 26 | def tv_type(self) -> str: 27 | """Return TV type (webos, tizen, android)""" 28 | pass 29 | 30 | @property 31 | @abstractmethod 32 | def manufacturer(self) -> str: 33 | """Return TV manufacturer (lg, samsung, sony)""" 34 | pass 35 | 36 | def get_name(self) -> str: 37 | """Get TV name from configuration""" 38 | tv_instances = settings.get('clients', {}).get('tvs', {}).get('instances', {}) 39 | for instance_id, instance in tv_instances.items(): 40 | if (instance.get('ip') == self.ip and 41 | instance.get('mac') == self.mac and 42 | instance.get('type') == self.tv_type): 43 | # Format the name from the instance_id 44 | words = instance_id.split('_') 45 | return ' '.join(word.capitalize() for word in words) 46 | return f"{self.manufacturer.upper()} TV ({self.ip})" 47 | 48 | def _load_config(self) -> Dict[str, Any]: 49 | """Load TV configuration from settings""" 50 | tv_settings = settings.get('clients', {}).get('tv', {}) 51 | if tv_settings.get('type') == self.tv_type and tv_settings.get('model') == self.manufacturer: 52 | return tv_settings 53 | return {} 54 | 55 | def get_app_id(self, service: str) -> Optional[str]: 56 | """Get platform-specific app ID for given service""" 57 | # Default app IDs for different platforms 58 | default_app_ids = { 59 | 'webos': { 60 | 'plex': ['plex', 'plexapp', 'plex media player'], 61 | 'jellyfin': ['jellyfin', 'jellyfin media player', 'jellyfin for webos'], 62 | 'emby': ['emby', 'embytv', 'emby theater', 'emby for webos', 'emby for lg'] 63 | }, 64 | 'tizen': { 65 | 'plex': 'QJxQHhr3rY', # Tizen Plex app ID 66 | 'jellyfin': 'jellyfin.tizen', 67 | 'emby': 'emby.tizen' 68 | }, 69 | 'android': { 70 | 'plex': 'com.plexapp.android.tv', 71 | 'jellyfin': 'org.jellyfin.androidtv', 72 | 'emby': 'tv.emby.embyatv' 73 | } 74 | } 75 | 76 | if self.tv_type in default_app_ids: 77 | return default_app_ids[self.tv_type].get(service) 78 | 79 | # Fallback to config if exists 80 | app_ids = self._config.get('app_ids', {}).get(self.tv_type, {}) 81 | return app_ids.get(service) 82 | 83 | def send_wol(self) -> bool: 84 | """Send Wake-on-LAN packet to TV""" 85 | if not self.mac: 86 | logger.error("Cannot send WoL packet: No MAC address configured") 87 | return False 88 | 89 | try: 90 | # Clean up MAC address format 91 | mac_address = str(self.mac).replace(':', '').replace('-', '') 92 | if len(mac_address) != 12: 93 | raise ValueError("Invalid MAC address format") 94 | 95 | # Create magic packet 96 | data = bytes.fromhex('FF' * 6 + mac_address * 16) 97 | 98 | # Send packet 99 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 100 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 101 | sock.sendto(data, ('', 9)) 102 | 103 | logger.info(f"Wake-on-LAN packet sent to {self.mac}") 104 | return True 105 | 106 | except Exception as e: 107 | logger.error(f"Failed to send Wake-on-LAN magic packet: {e}") 108 | return False 109 | 110 | @abstractmethod 111 | async def connect(self) -> bool: 112 | """Establish connection to TV""" 113 | pass 114 | 115 | @abstractmethod 116 | async def disconnect(self) -> bool: 117 | """Close connection to TV""" 118 | pass 119 | 120 | @abstractmethod 121 | async def is_available(self) -> bool: 122 | """Check if TV is available on the network""" 123 | pass 124 | 125 | @abstractmethod 126 | async def get_installed_apps(self) -> list: 127 | """Get list of installed apps""" 128 | pass 129 | 130 | @abstractmethod 131 | async def launch_app(self, app_id: str) -> bool: 132 | """Launch app with given ID""" 133 | pass 134 | 135 | async def launch_service(self, service: str) -> bool: 136 | """Launch media service (plex, jellyfin, emby)""" 137 | app_id = self.get_app_id(service) 138 | if not app_id: 139 | logger.error(f"No app ID found for service: {service}") 140 | return False 141 | 142 | return await self.launch_app(app_id) 143 | 144 | async def turn_on(self, app_to_launch: Optional[str] = None) -> bool: 145 | """Turn on TV and optionally launch an app""" 146 | # If we have a MAC address, always start with WoL 147 | if self.mac: 148 | logger.info(f"Sending WoL packet to {self.mac}") 149 | try: 150 | # Try async send_wol if implemented by child class 151 | if hasattr(self, 'async_send_wol'): 152 | if not await self.async_send_wol(): 153 | logger.error("Failed to send WoL packet (async)") 154 | return False 155 | # Fall back to sync send_wol from base class 156 | else: 157 | if not self.send_wol(): 158 | logger.error("Failed to send WoL packet") 159 | return False 160 | except Exception as e: 161 | logger.error(f"Error sending WoL packet: {e}") 162 | return False 163 | 164 | # Give TV time to wake up 165 | if not await self._wait_for_tv(): 166 | logger.error("TV failed to wake up after WoL") 167 | return False 168 | else: 169 | # No MAC address, try to connect directly 170 | if not await self.is_available(): 171 | logger.error("TV is not available and no MAC address configured for WoL") 172 | return False 173 | 174 | if not await self.connect(): 175 | return False 176 | 177 | if app_to_launch: 178 | return await self.launch_service(app_to_launch) 179 | 180 | return True 181 | 182 | @abstractmethod 183 | async def _wait_for_tv(self): 184 | """Wait for TV to become available after wake-on-lan""" 185 | pass 186 | 187 | @abstractmethod 188 | async def get_power_state(self) -> str: 189 | """Get current power state of TV""" 190 | pass 191 | 192 | class TVError(Exception): 193 | """Base exception for TV-related errors""" 194 | pass 195 | 196 | class TVConnectionError(TVError): 197 | """Error establishing connection to TV""" 198 | pass 199 | 200 | class TVAuthenticationError(TVError): 201 | """Error authenticating with TV""" 202 | pass 203 | 204 | class TVAppError(TVError): 205 | """Error launching or controlling apps""" 206 | pass 207 | -------------------------------------------------------------------------------- /utils/tv/base/tv_discovery.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import subprocess 4 | import shlex 5 | from typing import List, Dict, Any, Optional 6 | from abc import ABC, abstractmethod 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | class TVDiscoveryBase(ABC): 11 | """Base class for TV discovery implementations""" 12 | 13 | @abstractmethod 14 | def get_name(self) -> str: 15 | """Get name of this TV type (e.g., 'LG WebOS', 'Samsung Tizen')""" 16 | pass 17 | 18 | @abstractmethod 19 | def get_mac_prefixes(self) -> Dict[str, str]: 20 | """Get mapping of MAC prefixes to device descriptions""" 21 | pass 22 | 23 | def get_warning_message(self) -> Optional[str]: 24 | """Get implementation-specific warning message or None if not needed""" 25 | return None 26 | 27 | def _get_default_interface(self) -> Optional[str]: 28 | """Try to determine the default network interface.""" 29 | try: 30 | cmd = "ip route get 1.1.1.1" 31 | result = subprocess.run(shlex.split(cmd), capture_output=True, text=True, check=True, timeout=5) 32 | output = result.stdout.strip() 33 | match = re.search(r'dev\s+(\S+)', output) 34 | if match: 35 | interface = match.group(1) 36 | logger.info(f"Detected default network interface: {interface}") 37 | return interface 38 | else: 39 | logger.warning(f"Could not parse interface from 'ip route' output: {output}") 40 | return None 41 | except FileNotFoundError: 42 | logger.error("'ip' command not found. Cannot determine default interface. Falling back.") 43 | return None 44 | except subprocess.CalledProcessError as e: 45 | logger.error(f"Error running 'ip route': {e}. stderr: {e.stderr}") 46 | return None 47 | except subprocess.TimeoutExpired: 48 | logger.error("Timeout running 'ip route'.") 49 | return None 50 | except Exception as e: 51 | logger.error(f"Unexpected error getting default interface: {e}") 52 | return None 53 | 54 | def scan_network(self) -> List[Dict[str, str]]: 55 | """Scan network for TVs of this type""" 56 | try: 57 | interface = self._get_default_interface() 58 | if not interface: 59 | logger.warning("Could not detect default interface, falling back to 'eth0'.") 60 | interface = 'eth0' 61 | 62 | arp_scan_cmd = ['arp-scan', '-I', interface, '--localnet'] 63 | logger.info(f"Running arp-scan command: {' '.join(arp_scan_cmd)}") 64 | 65 | from utils.settings import settings 66 | blacklisted_macs = settings.get('clients', {}).get('tvs', {}).get('blacklist', {}).get('mac_addresses', []) 67 | logger.debug(f"Loaded blacklist: {blacklisted_macs}") 68 | result = subprocess.run( 69 | arp_scan_cmd, 70 | capture_output=True, 71 | text=True, 72 | check=True, 73 | timeout=30 74 | ) 75 | devices = [] 76 | for line in result.stdout.splitlines(): 77 | if '\t' not in line: 78 | continue 79 | parts = line.strip().split('\t') 80 | if len(parts) >= 2: 81 | ip, mac = parts[0], parts[1] 82 | desc = parts[2] if len(parts) > 2 else None 83 | 84 | if mac.lower() in (addr.lower() for addr in blacklisted_macs): 85 | logger.debug(f"Skipping blacklisted device: {mac}") 86 | continue 87 | 88 | mac_prefix = mac.upper()[:8] 89 | if mac_prefix in self.get_mac_prefixes() or self._is_tv_device(desc): 90 | warning_msg = self.get_warning_message() 91 | 92 | device = { 93 | 'ip': ip, 94 | 'mac': mac, 95 | 'description': desc or self.get_mac_prefixes().get(mac_prefix, f'{self.get_name()} Device'), 96 | 'device_type': self.get_mac_prefixes().get(mac_prefix, f'Unknown {self.get_name()} Model'), 97 | 'untested': bool(warning_msg), 98 | 'warning': warning_msg 99 | } 100 | 101 | self._enrich_device_info(device) 102 | devices.append(device) 103 | logger.info(f"Found {self.get_name()} device: {ip} ({mac}) - {desc}") 104 | logger.info(warning_msg) 105 | return devices 106 | except subprocess.CalledProcessError as e: 107 | logger.error(f"Error running arp-scan on interface {interface}: {e}. stderr: {e.stderr}") 108 | return [] 109 | except subprocess.TimeoutExpired: 110 | logger.error(f"Timeout running arp-scan on interface {interface}.") 111 | return [] 112 | except Exception as e: 113 | logger.error(f"Error during network scan on interface {interface}: {e}") 114 | return [] 115 | 116 | @abstractmethod 117 | def _is_tv_device(self, description: Optional[str]) -> bool: 118 | """Check if device description matches this TV type""" 119 | pass 120 | 121 | def _enrich_device_info(self, device: Dict[str, Any]): 122 | """Add additional device-specific information""" 123 | pass 124 | 125 | class TVDiscoveryFactory: 126 | """Factory for creating TV discovery implementations""" 127 | 128 | _discoveries = {} 129 | 130 | @classmethod 131 | def register(cls, tv_type: str, discovery_class): 132 | """Register a discovery implementation for a TV type""" 133 | cls._discoveries[tv_type] = discovery_class 134 | 135 | @classmethod 136 | def get_discovery(cls, tv_type: str) -> Optional[TVDiscoveryBase]: 137 | """Get discovery implementation for given TV type""" 138 | if tv_type not in cls._discoveries: 139 | if tv_type == 'webos': 140 | from ..discovery.webos_discovery import WebOSDiscovery 141 | cls.register('webos', WebOSDiscovery()) 142 | elif tv_type == 'tizen': 143 | from ..discovery.tizen_discovery import TizenDiscovery 144 | cls.register('tizen', TizenDiscovery()) 145 | elif tv_type == 'android': 146 | from ..discovery.android_discovery import AndroidDiscovery 147 | cls.register('android', AndroidDiscovery()) 148 | 149 | return cls._discoveries.get(tv_type) 150 | -------------------------------------------------------------------------------- /utils/tv/base/tv_factory.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional, Type, List 3 | from utils.settings import settings 4 | from .tv_base import TVControlBase 5 | from ..implementations import AndroidTV, TizenTV, WebOSTV 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | class TVFactory: 10 | """Factory for creating TV controller instances""" 11 | 12 | # Map of TV types to their controller classes 13 | TV_TYPES = { 14 | 'android': { 15 | 'sony': AndroidTV 16 | }, 17 | 'tizen': { 18 | 'samsung': TizenTV 19 | }, 20 | 'webos': { 21 | 'lg': WebOSTV 22 | } 23 | } 24 | 25 | @classmethod 26 | def get_all_tv_controllers(cls) -> List[TVControlBase]: 27 | """ 28 | Create TV controllers for all configured TVs 29 | Returns: 30 | List[TVControlBase]: List of TV controller instances 31 | """ 32 | controllers = [] 33 | try: 34 | # Get dynamic TV configurations 35 | tv_settings = settings.get('clients', {}).get('tvs', {}).get('instances', {}) 36 | for tv_name, tv_config in tv_settings.items(): 37 | if tv_config.get('enabled', True): # enabled by default 38 | tv_type = tv_config.get('type') 39 | tv_model = tv_config.get('model') 40 | 41 | controller = cls._create_controller(tv_type, tv_model, tv_config) 42 | if controller: 43 | controllers.append(controller) 44 | 45 | except Exception as e: 46 | logger.error(f"Error creating TV controllers: {e}") 47 | 48 | return controllers 49 | 50 | @classmethod 51 | def get_tv_controller(cls, tv_name: Optional[str] = None) -> Optional[TVControlBase]: 52 | """ 53 | Create a TV controller based on configuration 54 | Args: 55 | tv_name: Optional name of specific TV to get controller for 56 | Returns: 57 | TVControlBase: TV controller instance or None if not configured 58 | """ 59 | try: 60 | # If no specific TV requested, return first available 61 | if not tv_name: 62 | controllers = cls.get_all_tv_controllers() 63 | return controllers[0] if controllers else None 64 | 65 | # Get specific TV configuration 66 | tv_settings = settings.get('clients', {}).get('tvs', {}).get('instances', {}) 67 | tv_config = tv_settings.get(tv_name, {}) 68 | 69 | if tv_config.get('enabled', True): 70 | return cls._create_controller( 71 | tv_config.get('type'), 72 | tv_config.get('model'), 73 | tv_config 74 | ) 75 | 76 | except Exception as e: 77 | logger.error(f"Error creating TV controller: {e}") 78 | 79 | return None 80 | 81 | @classmethod 82 | def _create_controller(cls, tv_type: str, tv_model: str, config: dict) -> Optional[TVControlBase]: 83 | """ 84 | Create a TV controller instance based on type and model 85 | Args: 86 | tv_type: Type of TV (webos/tizen/android) 87 | tv_model: Model of TV (lg/samsung/sony) - can be None, will be derived from type 88 | config: TV configuration dictionary 89 | Returns: 90 | TVControlBase: TV controller instance or None if invalid config 91 | """ 92 | if not tv_type: 93 | logger.error("TV type not specified in settings") 94 | return None 95 | 96 | # Derive model from type if not provided 97 | if not tv_model: 98 | model_mapping = { 99 | 'webos': 'lg', 100 | 'tizen': 'samsung', 101 | 'android': 'sony' 102 | } 103 | tv_model = model_mapping.get(tv_type) 104 | if not tv_model: 105 | logger.error(f"Could not derive model for TV type: {tv_type}") 106 | return None 107 | 108 | # Get the appropriate controller class 109 | controller_class = cls.TV_TYPES.get(tv_type, {}).get(tv_model) 110 | if not controller_class: 111 | logger.error(f"No controller found for TV type '{tv_type}' and model '{tv_model}'") 112 | return None 113 | 114 | # Create controller instance 115 | try: 116 | return controller_class( 117 | ip=config.get('ip'), 118 | mac=config.get('mac') 119 | ) 120 | except Exception as e: 121 | logger.error(f"Error creating controller instance: {e}") 122 | return None 123 | 124 | @classmethod 125 | def get_supported_types(cls) -> dict: 126 | """Get list of supported TV types and models""" 127 | return cls.TV_TYPES 128 | 129 | @classmethod 130 | def get_controller_class(cls, tv_type: str, model: str) -> Optional[Type[TVControlBase]]: 131 | """Get controller class for specific TV type and model""" 132 | return cls.TV_TYPES.get(tv_type, {}).get(model) 133 | -------------------------------------------------------------------------------- /utils/tv/discovery/__init__.py: -------------------------------------------------------------------------------- 1 | from .android_discovery import AndroidDiscovery 2 | from .tizen_discovery import TizenDiscovery 3 | from .webos_discovery import WebOSDiscovery 4 | -------------------------------------------------------------------------------- /utils/tv/discovery/android_discovery.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | import socket 4 | from typing import Dict 5 | from typing import Optional 6 | import adb_shell.adb_device 7 | from adb_shell.auth.sign_pythonrsa import PythonRSASigner 8 | import zeroconf 9 | 10 | from ..base.tv_discovery import TVDiscoveryBase, TVDiscoveryFactory 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | class AndroidDiscovery(TVDiscoveryBase): 15 | """Discovery implementation for Sony Android TVs""" 16 | 17 | def get_mac_prefixes(self) -> Dict[str, str]: 18 | return { 19 | # Sony TV Specific 20 | '00:04:1F': 'Sony TV', 21 | '00:13:A9': 'Sony TV', 22 | '00:1A:80': 'Sony TV', 23 | '00:24:BE': 'Sony TV', 24 | '00:EB:2D': 'Sony TV', 25 | '04:5D:4B': 'Sony TV', 26 | '10:4F:A8': 'Sony TV', 27 | '18:E3:BC': 'Sony TV', 28 | '24:21:AB': 'Sony TV', 29 | '30:52:CB': 'Sony TV', 30 | '34:99:71': 'Sony TV', 31 | '3C:01:EF': 'Sony TV', 32 | '40:2B:A1': 'Sony TV', 33 | '54:42:49': 'Sony TV', 34 | '58:17:0C': 'Sony TV', 35 | '78:84:3C': 'Sony TV', 36 | '7C:6D:62': 'Sony TV', 37 | '84:C7:EA': 'Sony TV', 38 | '8C:B8:4A': 'Sony TV', 39 | '94:CE:2C': 'Sony TV', 40 | 'A0:E4:53': 'Sony TV', 41 | 'AC:9B:0A': 'Sony TV', 42 | 'BC:30:7D': 'Sony TV', 43 | 'BC:60:A7': 'Sony TV', 44 | 'E4:11:5B': 'Sony TV', 45 | 'FC:F1:52': 'Sony TV', 46 | } 47 | 48 | def get_name(self) -> str: 49 | return "Sony Android" 50 | 51 | def _is_tv_device(self, desc: str) -> bool: 52 | """Check if description indicates a Sony Android TV""" 53 | keywords = ['sony', 'bravia', 'android tv'] 54 | desc_lower = desc.lower() 55 | return any(keyword in desc_lower for keyword in keywords) 56 | 57 | def _enrich_device_info(self, device: Dict): 58 | """Add Android TV specific information to device info""" 59 | device['platform'] = 'android' 60 | device['adb_port'] = 5555 61 | device['pairing_port'] = 6466 62 | 63 | async def _discover_mdns(self): 64 | """Discover Android TVs using mDNS""" 65 | discovered = [] 66 | 67 | class AndroidTVListener: 68 | def add_service(self, zc, type_, name): 69 | info = zc.get_service_info(type_, name) 70 | if info: 71 | discovered.append({ 72 | 'ip': socket.inet_ntoa(info.addresses[0]), 73 | 'name': info.name, 74 | 'port': info.port 75 | }) 76 | 77 | zc = zeroconf.Zeroconf() 78 | listener = AndroidTVListener() 79 | browser = zeroconf.ServiceBrowser(zc, "_androidtvremote._tcp.local.", listener) 80 | 81 | # Wait a bit for discovery 82 | await asyncio.sleep(3) 83 | zc.close() 84 | 85 | return discovered 86 | 87 | async def test_connection(self, ip: str) -> bool: 88 | """Test if TV is reachable using multiple methods""" 89 | logger.info(f"Testing connection to TV at {ip}") 90 | 91 | try: 92 | # First try netcat for ADB port 93 | result = await self._test_connection_nc(ip) 94 | if result: 95 | return True 96 | 97 | # Then try socket connections 98 | android_ports = [5555, 5037, 6466] # ADB and pairing ports 99 | for port in android_ports: 100 | try: 101 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 102 | sock.settimeout(1) 103 | result = sock.connect_ex((ip, port)) 104 | sock.close() 105 | 106 | if result == 0: 107 | logger.info(f"Successfully connected to port {port}") 108 | return True 109 | except Exception as e: 110 | logger.debug(f"Socket connection to port {port} failed: {str(e)}") 111 | continue 112 | 113 | # Try ADB connection as final check 114 | try: 115 | device = adb_shell.adb_device.AdbDeviceTcp(ip, 5555, default_transport_timeout_s=3) 116 | device.connect() 117 | logger.info(f"Successfully connected via ADB to {ip}") 118 | device.close() 119 | return True 120 | except Exception as e: 121 | logger.debug(f"ADB connection failed: {str(e)}") 122 | 123 | return False 124 | 125 | except Exception as e: 126 | logger.error(f"Error testing TV connection: {str(e)}") 127 | return False 128 | 129 | async def _test_connection_nc(self, ip: str) -> bool: 130 | """Test TV connection using netcat""" 131 | logger.info(f"Testing connection to {ip} using netcat") 132 | try: 133 | ports = ['5555', '5037', '6466'] # ADB and pairing ports 134 | for port in ports: 135 | process = await asyncio.create_subprocess_exec( 136 | 'nc', '-zv', '-w1', ip, port, 137 | stdout=asyncio.subprocess.PIPE, 138 | stderr=asyncio.subprocess.PIPE 139 | ) 140 | await process.communicate() 141 | 142 | if process.returncode == 0: 143 | logger.info(f"Netcat successfully connected to {ip}:{port}") 144 | return True 145 | 146 | logger.info(f"No open ports found on {ip}") 147 | return False 148 | except Exception as e: 149 | logger.info(f"Netcat test failed: {str(e)}") 150 | return False 151 | 152 | def get_warning_message(self) -> Optional[str]: 153 | return ("Android TV implementation is currently not working. " 154 | "I am searching for ontributors and testers. " 155 | "If you're interested in helping, please open a GitHub issue at " 156 | "https://github.com/sahara101/Movie-Roulette.") 157 | 158 | async def scan_network_mdns(self): 159 | """Additional scan using mDNS for Android TV discovery""" 160 | try: 161 | mdns_devices = await self._discover_mdns() 162 | devices = [] 163 | 164 | for device in mdns_devices: 165 | ip = device['ip'] 166 | try: 167 | # Get MAC address using ARP 168 | result = await asyncio.create_subprocess_exec( 169 | 'arp', '-n', ip, 170 | stdout=asyncio.subprocess.PIPE, 171 | stderr=asyncio.subprocess.PIPE 172 | ) 173 | stdout, _ = await result.communicate() 174 | 175 | mac = None 176 | for line in stdout.decode().splitlines(): 177 | if ip in line: 178 | parts = line.split() 179 | if len(parts) >= 3: 180 | mac = parts[2].upper() 181 | 182 | if mac: 183 | device_info = { 184 | 'ip': ip, 185 | 'mac': mac, 186 | 'description': f"Sony Android TV ({device['name']})", 187 | 'device_type': 'Android TV', 188 | 'manufacturer': self.get_name() 189 | } 190 | self._enrich_device_info(device_info) 191 | devices.append(device_info) 192 | 193 | except Exception as e: 194 | logger.error(f"Error processing mDNS device {ip}: {e}") 195 | 196 | return devices 197 | 198 | except Exception as e: 199 | logger.error(f"Error in mDNS scan: {e}") 200 | return [] 201 | 202 | # Register the discoverer 203 | TVDiscoveryFactory.register('android', AndroidDiscovery()) 204 | -------------------------------------------------------------------------------- /utils/tv/discovery/tizen_discovery.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | import socket 4 | from typing import Optional 5 | from typing import Dict 6 | 7 | from ..base.tv_discovery import TVDiscoveryBase, TVDiscoveryFactory 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | class TizenDiscovery(TVDiscoveryBase): 12 | """Discovery implementation for Samsung Tizen TVs""" 13 | 14 | def get_mac_prefixes(self) -> Dict[str, str]: 15 | return { 16 | # Samsung TV/Display Specific 17 | '00:07:AB': 'Samsung Tizen TV', 18 | '00:12:47': 'Samsung Tizen TV', 19 | '00:15:B9': 'Samsung Tizen TV', 20 | '00:17:C9': 'Samsung Tizen TV', 21 | '00:1C:43': 'Samsung Tizen TV', 22 | '00:21:19': 'Samsung Tizen TV', 23 | '00:23:39': 'Samsung Tizen TV', 24 | '00:24:54': 'Samsung Tizen TV', 25 | '00:26:37': 'Samsung Tizen TV', 26 | '08:08:C2': 'Samsung Tizen TV', 27 | '08:D4:2B': 'Samsung Tizen TV', 28 | '0C:B3:19': 'Samsung Tizen TV', 29 | '0C:F3:61': 'Samsung Tizen TV', 30 | '10:1D:C0': 'Samsung Tizen TV', 31 | '10:3D:B4': 'Samsung Tizen TV', 32 | '18:3F:47': 'Samsung Tizen TV', 33 | '2C:0B:E9': 'Samsung Tizen TV', 34 | '2C:44:01': 'Samsung Tizen TV', 35 | '34:31:11': 'Samsung Tizen TV', 36 | '38:01:95': 'Samsung Tizen TV', 37 | '38:16:D7': 'Samsung Tizen TV', 38 | '40:0E:85': 'Samsung Tizen TV', 39 | '40:16:3B': 'Samsung Tizen TV', 40 | '48:27:EA': 'Samsung Tizen TV', 41 | '48:44:F7': 'Samsung Tizen TV', 42 | '4C:BC:98': 'Samsung Tizen TV', 43 | '50:85:69': 'Samsung Tizen TV', 44 | '50:B7:C3': 'Samsung Tizen TV', 45 | '54:92:BE': 'Samsung Tizen TV', 46 | '54:BD:79': 'Samsung Tizen TV', 47 | '5C:49:7D': 'Samsung Tizen TV', 48 | '5C:E8:EB': 'Samsung Tizen TV', 49 | '64:1C:AE': 'Samsung Tizen TV', 50 | '68:05:71': 'Samsung Tizen TV', 51 | '68:27:37': 'Samsung Tizen TV', 52 | '70:2A:D5': 'Samsung Tizen TV', 53 | '78:47:1D': 'Samsung Tizen TV', 54 | '78:9E:D0': 'Samsung Tizen TV', 55 | '80:4E:81': 'Samsung Tizen TV', 56 | '84:25:DB': 'Samsung Tizen TV', 57 | '8C:71:F8': 'Samsung Tizen TV', 58 | '8C:77:12': 'Samsung Tizen TV', 59 | '90:F1:AA': 'Samsung Tizen TV', 60 | '94:35:0A': 'Samsung Tizen TV', 61 | '94:63:72': 'Samsung Tizen TV', 62 | '98:1D:FA': 'Samsung Tizen TV', 63 | '98:83:89': 'Samsung Tizen TV', 64 | 'A0:07:98': 'Samsung Tizen TV', 65 | 'A4:6C:F1': 'Samsung Tizen TV', 66 | 'A8:F2:A3': 'Samsung Tizen TV', 67 | 'AC:5A:14': 'Samsung Tizen TV', 68 | 'B0:72:BF': 'Samsung Tizen TV', 69 | 'B0:C5:59': 'Samsung Tizen TV', 70 | 'B4:79:A7': 'Samsung Tizen TV', 71 | 'B8:BB:AF': 'Samsung Tizen TV', 72 | 'BC:20:A4': 'Samsung Tizen TV', 73 | 'BC:44:86': 'Samsung Tizen TV', 74 | 'BC:72:B1': 'Samsung Tizen TV', 75 | 'BC:8C:CD': 'Samsung Tizen TV', 76 | 'C0:48:E6': 'Samsung Tizen TV', 77 | 'C0:97:27': 'Samsung Tizen TV', 78 | 'C4:57:6E': 'Samsung Tizen TV', 79 | 'CC:6E:A4': 'Samsung Tizen TV', 80 | 'D0:59:E4': 'Samsung Tizen TV', 81 | 'D4:40:F0': 'Samsung Tizen TV', 82 | 'D8:57:EF': 'Samsung Tizen TV', 83 | 'D8:90:E8': 'Samsung Tizen TV', 84 | 'E4:7C:F9': 'Samsung Tizen TV', 85 | 'E8:3A:12': 'Samsung Tizen TV', 86 | 'EC:E0:9B': 'Samsung Tizen TV', 87 | 'F4:7B:5E': 'Samsung Tizen TV', 88 | 'F4:9F:54': 'Samsung Tizen TV', 89 | 'F8:3F:51': 'Samsung Tizen TV', 90 | 'FC:03:9F': 'Samsung Tizen TV', 91 | } 92 | 93 | def get_name(self) -> str: 94 | return "Samsung Tizen" 95 | 96 | def _is_tv_device(self, desc: str) -> bool: 97 | """Check if description indicates a Samsung TV""" 98 | keywords = ['samsung', 'tizen', 'smart tv'] 99 | desc_lower = desc.lower() 100 | return any(keyword in desc_lower for keyword in keywords) 101 | 102 | def _enrich_device_info(self, device: Dict): 103 | """Add Tizen-specific information to device info""" 104 | device['platform'] = 'tizen' 105 | # Default Tizen ports 106 | device['ws_port'] = 8001 107 | device['api_port'] = 8002 108 | 109 | def get_warning_message(self) -> Optional[str]: 110 | return ("Samsung TV (Tizen) implementation is currently not working. " 111 | "I am searching for contributors and testers. " 112 | "If you're interested in helping, please open a GitHub issue at " 113 | "https://github.com/sahara101/Movie-Roulette.") 114 | 115 | async def test_connection(self, ip: str) -> bool: 116 | """Test if TV is reachable using multiple methods""" 117 | logger.info(f"Testing connection to TV at {ip}") 118 | 119 | try: 120 | # Try netcat first for common Tizen TV ports 121 | result = await self._test_connection_nc(ip) 122 | if result: 123 | return True 124 | 125 | # Then try socket connections to known Tizen ports 126 | tizen_ports = [8001, 8002, 8080] 127 | for port in tizen_ports: 128 | try: 129 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 130 | sock.settimeout(1) 131 | result = sock.connect_ex((ip, port)) 132 | sock.close() 133 | 134 | if result == 0: 135 | logger.info(f"Successfully connected to port {port}") 136 | return True 137 | except Exception as e: 138 | logger.debug(f"Socket connection to port {port} failed: {str(e)}") 139 | continue 140 | 141 | # Try REST API endpoint as final check 142 | try: 143 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 144 | sock.settimeout(2) 145 | sock.connect((ip, 8002)) 146 | 147 | # Try to get TV info 148 | request = ( 149 | b'GET /api/v2/information HTTP/1.1\r\n' 150 | b'Host: ' + ip.encode() + b'\r\n' 151 | b'Connection: close\r\n\r\n' 152 | ) 153 | sock.send(request) 154 | 155 | # Any response indicates TV is available 156 | if sock.recv(4096): 157 | logger.info(f"Successfully got TV info response from {ip}") 158 | return True 159 | 160 | except Exception as e: 161 | logger.debug(f"REST API check failed: {str(e)}") 162 | finally: 163 | sock.close() 164 | 165 | logger.info(f"TV at {ip} is not reachable") 166 | return False 167 | 168 | except Exception as e: 169 | logger.error(f"Error testing TV connection: {str(e)}") 170 | return False 171 | 172 | async def _test_connection_nc(self, ip: str) -> bool: 173 | """Test TV connection using netcat""" 174 | logger.info(f"Testing connection to {ip} using netcat") 175 | try: 176 | ports = ['8001', '8002', '8080'] 177 | for port in ports: 178 | process = await asyncio.create_subprocess_exec( 179 | 'nc', '-zv', '-w1', ip, port, 180 | stdout=asyncio.subprocess.PIPE, 181 | stderr=asyncio.subprocess.PIPE 182 | ) 183 | await process.communicate() 184 | 185 | if process.returncode == 0: 186 | logger.info(f"Netcat successfully connected to {ip}:{port}") 187 | return True 188 | 189 | logger.info(f"No open ports found on {ip}") 190 | return False 191 | except Exception as e: 192 | logger.info(f"Netcat test failed: {str(e)}") 193 | return False 194 | 195 | # Register the discoverer 196 | TVDiscoveryFactory.register('tizen', TizenDiscovery()) 197 | -------------------------------------------------------------------------------- /utils/tv/discovery/webos_discovery.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | from typing import Optional 4 | from typing import Dict 5 | 6 | from ..base.tv_discovery import TVDiscoveryBase, TVDiscoveryFactory 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | class WebOSDiscovery(TVDiscoveryBase): 11 | """Discovery implementation for LG WebOS TVs""" 12 | 13 | def get_mac_prefixes(self) -> Dict[str, str]: 14 | return { 15 | # LG Electronics Main 16 | '00:E0:70': 'LG Electronics', 17 | '00:1C:62': 'LG Electronics', 18 | '00:1E:75': 'LG Electronics', 19 | '00:1F:6B': 'LG Electronics', 20 | '00:1F:E3': 'LG Electronics', 21 | '00:22:A9': 'LG Electronics', 22 | '00:24:83': 'LG Electronics', 23 | '00:25:E5': 'LG Electronics', 24 | '00:26:E2': 'LG Electronics', 25 | '00:34:DA': 'LG Electronics', 26 | '00:3A:AF': 'LG Electronics', 27 | '00:50:BA': 'LG Electronics', 28 | '00:52:A1': 'LG Electronics', 29 | '00:AA:70': 'LG Electronics', 30 | 31 | # LG TV/Display Specific 32 | '00:5A:13': 'LG WebOS TV', 33 | '00:C0:38': 'LG TV', 34 | '04:4E:5A': 'LG WebOS TV', 35 | '08:D4:6A': 'LG TV', 36 | '10:68:3F': 'LG Electronics TV', 37 | '10:F1:F2': 'LG TV', 38 | '10:F9:6F': 'LG Electronics TV', 39 | '2C:54:CF': 'LG Electronics TV', 40 | '2C:59:8A': 'LG Electronics TV', 41 | '34:4D:F7': 'LG Electronics TV', 42 | '38:8C:50': 'LG Electronics TV', 43 | '3C:BD:D8': 'LG Electronics TV', 44 | '40:B0:FA': 'LG Electronics TV', 45 | '48:59:29': 'LG Electronics TV', 46 | '50:55:27': 'LG Electronics TV', 47 | '58:A2:B5': 'LG Electronics TV', 48 | '60:E3:AC': 'LG Electronics TV', 49 | '64:99:5D': 'LG Electronics TV', 50 | '6C:DD:BC': 'LG Electronics TV', 51 | '70:91:8F': 'LG Electronics TV', 52 | '74:A5:28': 'LG Electronics TV', 53 | '78:5D:C8': 'LG Electronics TV', 54 | '7C:1C:4E': 'LG Electronics TV', 55 | '7C:AB:25': 'LG WebOS TV', 56 | '88:36:6C': 'LG Electronics TV', 57 | '8C:3C:4A': 'LG Electronics TV', 58 | '98:93:CC': 'LG Electronics TV', 59 | '98:D6:F7': 'LG Electronics TV', 60 | 'A0:39:F7': 'LG Electronics TV', 61 | 'A8:16:B2': 'LG Electronics TV', 62 | 'A8:23:FE': 'LG Electronics TV', 63 | 'AC:0D:1B': 'LG Electronics TV', 64 | 'B4:0E:DC': 'LG Smart TV', 65 | 'B4:E6:2A': 'LG Electronics TV', 66 | 'B8:1D:AA': 'LG Electronics TV', 67 | 'B8:AD:3E': 'LG Electronics TV', 68 | 'BC:8C:CD': 'LG Smart TV', 69 | 'BC:F5:AC': 'LG Electronics TV', 70 | 'C4:36:6C': 'LG Electronics TV', 71 | 'C4:9A:02': 'LG Smart TV', 72 | 'C8:02:8F': 'LG Electronics TV', 73 | 'C8:08:E9': 'LG Electronics TV', 74 | 'CC:2D:8C': 'LG Electronics TV', 75 | 'CC:FA:00': 'LG Electronics TV', 76 | 'D0:13:FD': 'LG Electronics TV', 77 | 'D8:4D:2C': 'LG Electronics TV', 78 | 'DC:0B:34': 'LG Electronics TV', 79 | 'E8:5B:5B': 'LG Electronics TV', 80 | 'E8:F2:E2': 'LG Electronics TV', 81 | 'F0:1C:13': 'LG Electronics TV', 82 | 'F8:0C:F3': 'LG Electronics TV', 83 | 'FC:4D:8C': 'LG WebOS TV', 84 | } 85 | 86 | def get_name(self) -> str: 87 | return "LG WebOS" 88 | 89 | def _is_tv_device(self, desc: str) -> bool: 90 | return 'LG Electronics' in desc or 'WebOS' in desc 91 | 92 | async def test_connection(self, ip: str) -> bool: 93 | """Test if TV is reachable using multiple methods""" 94 | logger.info(f"Testing connection to TV at {ip}") 95 | 96 | try: 97 | # Try netcat first 98 | result = await self._test_connection_nc(ip) 99 | if result: 100 | return True 101 | 102 | # Then try socket connections 103 | webos_ports = [3000, 3001, 8080, 8001, 8002] 104 | for port in webos_ports: 105 | try: 106 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 107 | sock.settimeout(1) 108 | result = sock.connect_ex((ip, int(port))) 109 | sock.close() 110 | 111 | if result == 0: 112 | logger.info(f"Successfully connected to port {port}") 113 | return True 114 | except Exception as e: 115 | logger.debug(f"Socket connection to port {port} failed: {str(e)}") 116 | continue 117 | 118 | # Try hostname resolution as last resort 119 | try: 120 | socket.gethostbyaddr(ip) 121 | logger.info(f"IP {ip} is resolvable, considering TV reachable") 122 | return True 123 | except socket.herror: 124 | pass 125 | 126 | logger.info(f"TV at {ip} is not reachable") 127 | return False 128 | 129 | except Exception as e: 130 | logger.error(f"Error testing TV connection: {str(e)}") 131 | return False 132 | 133 | def get_warning_message(self) -> Optional[str]: 134 | return None # WebOS implementation is working, no warning needed 135 | 136 | async def _test_connection_nc(self, ip: str) -> bool: 137 | """Test TV connection using netcat""" 138 | logger.info(f"Testing connection to {ip} using netcat") 139 | try: 140 | # Test using netcat for common WebOS ports 141 | ports = ['3000', '3001', '8080', '8001', '8002'] 142 | for port in ports: 143 | process = await asyncio.create_subprocess_exec( 144 | 'nc', '-zv', '-w1', ip, port, 145 | stdout=asyncio.subprocess.PIPE, 146 | stderr=asyncio.subprocess.PIPE 147 | ) 148 | await process.communicate() 149 | 150 | if process.returncode == 0: 151 | logger.info(f"Netcat successfully connected to {ip}:{port}") 152 | return True 153 | 154 | logger.info(f"No open ports found on {ip}") 155 | return False 156 | except Exception as e: 157 | logger.info(f"Netcat test failed: {str(e)}") 158 | return False 159 | 160 | # Register the discoverer 161 | TVDiscoveryFactory.register('webos', WebOSDiscovery()) 162 | -------------------------------------------------------------------------------- /utils/tv/implementations/__init__.py: -------------------------------------------------------------------------------- 1 | from .android_tv import AndroidTV 2 | from .tizen_tv import TizenTV 3 | from .webos_tv import WebOSTV 4 | -------------------------------------------------------------------------------- /utils/tv/implementations/android_tv.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from pathlib import Path 4 | from adb_shell.auth.keygen import keygen 5 | from adb_shell.auth.sign_pythonrsa import PythonRSASigner 6 | import adb_shell.adb_device 7 | from typing import List, Optional 8 | 9 | from ..base.tv_base import TVControlBase, TVError, TVConnectionError, TVAppError 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | class AndroidTV(TVControlBase): 14 | """Implementation for Sony Android TV control""" 15 | 16 | def __init__(self, ip: str = None, mac: str = None): 17 | super().__init__(ip, mac) 18 | self.adb_port = self._config.get('android', {}).get('adb_port', '5555') 19 | self.pairing_port = self._config.get('android', {}).get('pairing_port', '6466') 20 | self._device = None 21 | self._default_app_ids = { 22 | 'plex': 'com.plexapp.android', 23 | 'jellyfin': 'org.jellyfin.androidtv', 24 | 'emby': 'tv.emby.embyatv' 25 | } 26 | self._load_adb_keys() 27 | 28 | @property 29 | def tv_type(self) -> str: 30 | return 'android' 31 | 32 | @property 33 | def manufacturer(self) -> str: 34 | return 'sony' 35 | 36 | def get_app_id(self, service: str) -> Optional[str]: 37 | """Get platform-specific app ID for given service""" 38 | return self._default_app_ids.get(service.lower()) 39 | 40 | def _load_adb_keys(self): 41 | """Load or create ADB key pair""" 42 | key_path = Path('/app/data/adbkey') 43 | try: 44 | if not key_path.exists(): 45 | keygen(str(key_path)) 46 | with open(key_path, 'rb') as f: 47 | private_key = f.read() 48 | with open(f"{key_path}.pub", 'rb') as f: 49 | public_key = f.read() 50 | self.signer = PythonRSASigner(public_key, private_key) 51 | logger.info("ADB keys loaded successfully") 52 | except Exception as e: 53 | logger.error(f"Failed to load ADB keys: {e}") 54 | raise TVConnectionError("Could not initialize ADB authentication") 55 | 56 | async def connect(self) -> bool: 57 | """Connect to TV via ADB""" 58 | try: 59 | if self._device and self._device.available: 60 | return True 61 | 62 | self._device = adb_shell.adb_device.AdbDeviceTcp( 63 | self.ip, 64 | int(self.adb_port), 65 | default_transport_timeout_s=9. 66 | ) 67 | 68 | connected = await asyncio.to_thread( 69 | self._device.connect, 70 | rsa_keys=[self.signer], 71 | auth_timeout_s=5 72 | ) 73 | 74 | if connected: 75 | logger.info(f"Connected to Android TV at {self.ip}") 76 | return True 77 | else: 78 | logger.error("Failed to connect to TV") 79 | return False 80 | 81 | except Exception as e: 82 | logger.error(f"Connection failed: {e}") 83 | return False 84 | 85 | async def disconnect(self) -> bool: 86 | """Disconnect from TV""" 87 | if self._device: 88 | try: 89 | await asyncio.to_thread(self._device.close) 90 | self._device = None 91 | return True 92 | except Exception as e: 93 | logger.error(f"Error disconnecting: {e}") 94 | return False 95 | 96 | async def is_available(self) -> bool: 97 | """Check if TV is available on network""" 98 | try: 99 | # First try basic socket connection 100 | try: 101 | import socket 102 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 103 | sock.settimeout(1) 104 | result = sock.connect_ex((self.ip, int(self.adb_port))) 105 | sock.close() 106 | if result != 0: 107 | logger.debug(f"Basic socket connection failed with result {result}") 108 | return False 109 | logger.debug("Basic socket connection successful") 110 | except Exception as e: 111 | logger.error(f"Socket test failed: {e}") 112 | return False 113 | 114 | # Then try ADB connection 115 | if not self._device: 116 | return await self.connect() 117 | return self._device.available 118 | 119 | except Exception as e: 120 | logger.error(f"TV not available: {e}") 121 | return False 122 | 123 | async def get_installed_apps(self) -> List[str]: 124 | """Get list of installed apps""" 125 | if not self._device: 126 | raise TVConnectionError("Not connected to TV") 127 | 128 | try: 129 | result = await asyncio.to_thread( 130 | self._device.shell, 131 | "pm list packages -3" 132 | ) 133 | apps = [line.split(':')[1] for line in result.splitlines() if ':' in line] 134 | return [{'app_id': app, 'name': app} for app in apps] 135 | except Exception as e: 136 | logger.error(f"Error getting installed apps: {e}") 137 | raise TVError(f"Failed to get app list: {e}") 138 | 139 | async def launch_app(self, app_id: str) -> bool: 140 | """Launch app with given ID""" 141 | if not self._device: 142 | raise TVConnectionError("Not connected to TV") 143 | 144 | try: 145 | # Try to launch the app using monkey tool first 146 | try: 147 | cmd = f"monkey -p {app_id} -c android.intent.category.LAUNCHER 1" 148 | result = await asyncio.to_thread(self._device.shell, cmd) 149 | if "Events injected: 1" in result: 150 | logger.info(f"Launched app {app_id} using monkey tool") 151 | return True 152 | except Exception as e: 153 | logger.debug(f"Monkey tool launch failed: {e}, trying activity manager") 154 | 155 | # Fallback to activity manager 156 | try: 157 | cmd = f"am start -n {app_id}/.MainActivity" 158 | await asyncio.to_thread(self._device.shell, cmd) 159 | logger.info(f"Launched app {app_id} using activity manager") 160 | return True 161 | except Exception as e: 162 | logger.error(f"All launch methods failed for app {app_id}: {e}") 163 | raise TVAppError(f"Failed to launch app: {e}") 164 | 165 | except Exception as e: 166 | logger.error(f"Error launching app: {e}") 167 | raise TVAppError(f"Failed to launch app: {e}") 168 | 169 | async def get_power_state(self) -> str: 170 | """Get TV power state""" 171 | if await self.is_available(): 172 | return "on" 173 | return "off" 174 | 175 | async def _wait_for_tv(self): 176 | """Wait for TV to become available after wake-on-lan""" 177 | max_attempts = 20 178 | for attempt in range(max_attempts): 179 | if await self.is_available(): 180 | logger.info(f"TV became available after {attempt + 1} attempts") 181 | await asyncio.sleep(2) # Give it a little more time to stabilize 182 | return True 183 | await asyncio.sleep(1) 184 | return False 185 | -------------------------------------------------------------------------------- /utils/tv/implementations/tizen_tv.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import base64 4 | import json 5 | import time 6 | import websocket 7 | from typing import List, Optional 8 | from wakeonlan import send_magic_packet 9 | 10 | from ..base.tv_base import TVControlBase, TVError, TVConnectionError, TVAppError 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | class SamsungTVWS: 15 | URL_FORMAT = 'ws://{host}:{port}/api/v2/channels/samsung.remote.control?name={name}' 16 | KEY_INTERVAL = 1.5 17 | 18 | def __init__(self, host, port=8001, name='MovieRoulette'): 19 | self.connection = None 20 | self.host = host 21 | self.port = port 22 | self.name = name 23 | 24 | def _serialize_string(self, string): 25 | if isinstance(string, str): 26 | string = str.encode(string) 27 | return base64.b64encode(string).decode('utf-8') 28 | 29 | def connect(self): 30 | try: 31 | self.connection = websocket.create_connection( 32 | self.URL_FORMAT.format( 33 | host=self.host, 34 | port=self.port, 35 | name=self._serialize_string(self.name) 36 | ) 37 | ) 38 | response = json.loads(self.connection.recv()) 39 | if response['event'] != 'ms.channel.connect': 40 | self.close() 41 | raise Exception(response) 42 | return True 43 | except Exception as e: 44 | logger.error(f"Connection failed: {e}") 45 | return False 46 | 47 | def close(self): 48 | if self.connection: 49 | self.connection.close() 50 | self.connection = None 51 | logger.debug('Connection closed.') 52 | 53 | def send_key(self, key, repeat=1): 54 | if not self.connection: 55 | raise Exception("Not connected") 56 | 57 | for n in range(repeat): 58 | payload = json.dumps({ 59 | 'method': 'ms.remote.control', 60 | 'params': { 61 | 'Cmd': 'Click', 62 | 'DataOfCmd': key, 63 | 'Option': 'false', 64 | 'TypeOfRemote': 'SendRemoteKey' 65 | } 66 | }) 67 | logger.info(f'Sending key {key}') 68 | self.connection.send(payload) 69 | time.sleep(self.KEY_INTERVAL) 70 | 71 | class TizenTV(TVControlBase): 72 | """Implementation for Samsung Tizen TV control""" 73 | 74 | def __init__(self, ip: str = None, mac: str = None): 75 | super().__init__(ip, mac) 76 | self._tv = None 77 | self._default_app_ids = { 78 | 'plex': '3201512006963', 79 | 'jellyfin': 'AprZAARz4r.Jellyfin', 80 | 'emby': '3201606009872' 81 | } 82 | 83 | @property 84 | def tv_type(self) -> str: 85 | return 'tizen' 86 | 87 | @property 88 | def manufacturer(self) -> str: 89 | return 'samsung' 90 | 91 | def send_wol(self) -> bool: 92 | """Send Wake-on-LAN packet to TV""" 93 | if not self.mac: 94 | logger.error("Cannot send WoL packet: No MAC address configured") 95 | return False 96 | 97 | try: 98 | # Send to both ports commonly used by Samsung TVs 99 | for port in [7, 9]: 100 | try: 101 | send_magic_packet(self.mac, port=port) 102 | logger.info(f"Sent WoL packet to {self.mac} on port {port}") 103 | except Exception as e: 104 | logger.debug(f"Failed to send WoL on port {port}: {e}") 105 | 106 | return True 107 | except Exception as e: 108 | logger.error(f"Failed to send Wake-on-LAN packet: {e}") 109 | return False 110 | 111 | async def connect(self) -> bool: 112 | """Connect to TV via WebSocket""" 113 | try: 114 | if self._tv and self._tv.connection: 115 | return True 116 | 117 | logger.info(f"Attempting to connect to TV at {self.ip}") 118 | self._tv = SamsungTVWS(host=self.ip, port=8001, name='MovieRoulette') 119 | 120 | # Run the synchronous connect in the executor 121 | success = await asyncio.get_event_loop().run_in_executor(None, self._tv.connect) 122 | 123 | if success: 124 | logger.info("Successfully connected to TV") 125 | try: 126 | # Try to wake up TV via key command 127 | await asyncio.get_event_loop().run_in_executor(None, self._tv.send_key, 'KEY_POWER') 128 | except: 129 | pass # Ignore if power key fails 130 | return True 131 | 132 | return False 133 | 134 | except Exception as e: 135 | logger.error(f"Connection failed: {e}") 136 | return False 137 | 138 | async def disconnect(self) -> bool: 139 | """Disconnect from TV""" 140 | if self._tv: 141 | try: 142 | await asyncio.get_event_loop().run_in_executor(None, self._tv.close) 143 | self._tv = None 144 | return True 145 | except Exception as e: 146 | logger.error(f"Error disconnecting: {e}") 147 | return False 148 | 149 | async def is_available(self) -> bool: 150 | """Check if TV is available on network""" 151 | try: 152 | if not self._tv: 153 | return await self.connect() 154 | return bool(self._tv.connection) 155 | except Exception as e: 156 | logger.debug(f"TV availability check failed: {str(e)}") 157 | return False 158 | 159 | async def _wait_for_tv(self): 160 | """Wait for TV to become available after wake-on-lan""" 161 | max_attempts = 5 162 | for attempt in range(max_attempts): 163 | logger.info(f"Waiting for TV to wake up (attempt {attempt + 1}/{max_attempts})") 164 | if await self.is_available(): 165 | logger.info(f"TV became available after {attempt + 1} attempts") 166 | await asyncio.sleep(2) # Give it a little more time to stabilize 167 | return True 168 | await asyncio.sleep(3) # Longer delay between attempts 169 | return False 170 | 171 | def get_app_id(self, service: str) -> Optional[str]: 172 | """Get platform-specific app ID for given service""" 173 | return self._default_app_ids.get(service.lower()) 174 | 175 | async def get_installed_apps(self) -> List[str]: 176 | """Not implemented for basic version""" 177 | logger.warning("get_installed_apps not implemented") 178 | return [] 179 | 180 | async def launch_app(self, app_id: str) -> bool: 181 | """Launch app using REST API""" 182 | if not self._tv: 183 | raise TVConnectionError("Not connected to TV") 184 | 185 | try: 186 | # Use REST API to launch app 187 | import requests 188 | url = f"http://{self.ip}:8001/api/v2/applications/{app_id}" 189 | response = requests.post(url) 190 | 191 | if response.status_code == 200: 192 | logger.info(f"Successfully launched app {app_id}") 193 | return True 194 | else: 195 | logger.error(f"Failed to launch app, status code: {response.status_code}") 196 | return False 197 | 198 | except Exception as e: 199 | logger.error(f"Error launching app: {e}") 200 | raise TVAppError(f"Failed to launch app: {e}") 201 | 202 | async def get_power_state(self) -> str: 203 | """Get TV power state""" 204 | if await self.is_available(): 205 | return "on" 206 | return "off" 207 | -------------------------------------------------------------------------------- /utils/tv/implementations/webos_tv.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import json 4 | from typing import List, Optional 5 | from pywebostv.connection import WebOSClient 6 | from pywebostv.controls import ApplicationControl 7 | 8 | from ..base.tv_base import TVControlBase, TVError, TVConnectionError, TVAppError 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | class WebOSTV(TVControlBase): 13 | """Implementation for LG WebOS TV control""" 14 | 15 | def __init__(self, ip: str = None, mac: str = None): 16 | super().__init__(ip, mac) 17 | self._store_path = '/app/data/webos_store.json' 18 | self._client = None 19 | self._app_ids = { 20 | 'plex': ['plex', 'plexapp', 'plex media player'], 21 | 'jellyfin': ['jellyfin', 'jellyfin media player', 'jellyfin for webos'], 22 | 'emby': ['emby', 'embytv', 'emby theater', 'emby for webos', 'emby for lg'] 23 | } 24 | 25 | @property 26 | def tv_type(self) -> str: 27 | return 'webos' 28 | 29 | @property 30 | def manufacturer(self) -> str: 31 | return 'lg' 32 | 33 | def _load_store(self) -> dict: 34 | """Load TV client store from disk""" 35 | try: 36 | with open(self._store_path, 'r') as f: 37 | return json.load(f) 38 | except FileNotFoundError: 39 | return {} 40 | except Exception as e: 41 | logger.error(f"Error loading store: {e}") 42 | return {} 43 | 44 | def _save_store(self, store: dict): 45 | """Save TV client store to disk""" 46 | try: 47 | with open(self._store_path, 'w') as f: 48 | json.dump(store, f) 49 | except Exception as e: 50 | logger.error(f"Error saving store: {e}") 51 | 52 | async def connect(self) -> bool: 53 | """Connect to TV via WebSocket""" 54 | try: 55 | if not self.ip: 56 | raise TVConnectionError("No IP address configured") 57 | 58 | logger.info(f"Attempting to connect to TV at {self.ip}") 59 | store = self._load_store() 60 | 61 | self._client = WebOSClient(self.ip) 62 | self._client.connect() 63 | 64 | for status in self._client.register(store): 65 | if status == WebOSClient.PROMPTED: 66 | logger.info("Please accept the connection on your TV") 67 | elif status == WebOSClient.REGISTERED: 68 | logger.info("TV registration successful") 69 | self._save_store(store) 70 | return True 71 | 72 | return False 73 | 74 | except Exception as e: 75 | logger.error(f"Connection failed: {e}") 76 | self._client = None 77 | raise TVConnectionError(f"Failed to connect to TV: {e}") 78 | 79 | async def disconnect(self) -> bool: 80 | """Disconnect from TV""" 81 | if self._client: 82 | try: 83 | self._client.close() 84 | self._client = None 85 | return True 86 | except Exception as e: 87 | logger.error(f"Error disconnecting: {e}") 88 | return False 89 | 90 | async def is_available(self) -> bool: 91 | """Check if TV is available on network""" 92 | try: 93 | if not self._client: 94 | await self.connect() 95 | return bool(self._client) 96 | except Exception: 97 | return False 98 | 99 | async def get_installed_apps(self) -> List[str]: 100 | """Get list of installed apps""" 101 | if not self._client: 102 | raise TVConnectionError("Not connected to TV") 103 | 104 | try: 105 | app_control = ApplicationControl(self._client) 106 | return app_control.list_apps() 107 | except Exception as e: 108 | logger.error(f"Error getting installed apps: {e}") 109 | raise TVError(f"Failed to get app list: {e}") 110 | 111 | async def launch_app(self, app_id: str) -> bool: 112 | """Launch app with given ID""" 113 | if not self._client: 114 | raise TVConnectionError("Not connected to TV") 115 | 116 | try: 117 | app_control = ApplicationControl(self._client) 118 | apps = app_control.list_apps() 119 | 120 | # Look for app matching any of the possible names 121 | target_app = None 122 | app_names = app_id if isinstance(app_id, list) else [app_id] 123 | 124 | for app in apps: 125 | app_title = app['title'].lower() 126 | if any(name in app_title for name in app_names): 127 | target_app = app 128 | break 129 | 130 | if target_app: 131 | app_control.launch(target_app) 132 | logger.info(f"Launched app: {target_app['title']}") 133 | return True 134 | else: 135 | logger.error(f"App not found: {app_id}") 136 | return False 137 | 138 | except Exception as e: 139 | logger.error(f"Error launching app: {e}") 140 | raise TVAppError(f"Failed to launch app: {e}") 141 | 142 | async def _wait_for_tv(self): 143 | """Wait for TV to become available after wake""" 144 | max_attempts = 20 145 | for attempt in range(max_attempts): 146 | if await self.is_available(): 147 | logger.info(f"TV became available after {attempt + 1} attempts") 148 | await asyncio.sleep(2) # Give it a little more time to stabilize 149 | return True 150 | await asyncio.sleep(1) 151 | return False 152 | 153 | async def get_power_state(self) -> str: 154 | """Get TV power state""" 155 | if await self.is_available(): 156 | return "on" 157 | return "off" 158 | 159 | def get_app_id(self, service: str) -> Optional[str]: 160 | """Override to return list of possible app names""" 161 | return self._app_ids.get(service.lower(), [service.lower()]) 162 | -------------------------------------------------------------------------------- /utils/user_cache.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import json 4 | from flask import session, g 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | class UserCacheManager: 9 | """Manages the relationship between users and their cache files""" 10 | 11 | def __init__(self, app): 12 | self.app = app 13 | self.users_data_dir = '/app/data/user_data' 14 | os.makedirs(self.users_data_dir, exist_ok=True) 15 | 16 | def get_user_cache_path(self, username, service='plex', cache_type='unwatched'): 17 | """Get the path to a user's cache file""" 18 | if not username: 19 | if service == 'plex': 20 | if cache_type == 'unwatched': 21 | return '/app/data/plex_unwatched_movies.json' 22 | else: 23 | return '/app/data/plex_all_movies.json' 24 | elif service == 'jellyfin': 25 | return '/app/data/jellyfin_all_movies.json' 26 | elif service == 'emby': 27 | return '/app/data/emby_all_movies.json' 28 | 29 | user_dir = os.path.join(self.users_data_dir, username) 30 | os.makedirs(user_dir, exist_ok=True) 31 | 32 | if service == 'plex': 33 | if cache_type == 'unwatched': 34 | return os.path.join(user_dir, 'plex_unwatched_movies.json') 35 | else: 36 | return os.path.join(user_dir, 'plex_all_movies.json') 37 | elif service == 'jellyfin': 38 | return os.path.join(user_dir, 'jellyfin_all_movies.json') 39 | elif service == 'emby': 40 | return os.path.join(user_dir, 'emby_all_movies.json') 41 | 42 | return os.path.join(user_dir, f'{service}_{cache_type}_movies.json') 43 | 44 | def get_user_stats(self, username): 45 | """Get statistics about a user's caches""" 46 | stats = { 47 | 'username': username, 48 | 'plex': { 49 | 'unwatched_count': 0, 50 | 'all_count': 0, 51 | 'cache_exists': False 52 | }, 53 | 'jellyfin': { 54 | 'all_count': 0, 55 | 'cache_exists': False 56 | }, 57 | 'emby': { 58 | 'all_count': 0, 59 | 'cache_exists': False 60 | } 61 | } 62 | 63 | if username.startswith('plex_') or username.startswith('plex_managed_'): 64 | plex_unwatched_user_path = self.get_user_cache_path(username, 'plex', 'unwatched') 65 | 66 | if os.path.exists(plex_unwatched_user_path): 67 | stats['plex']['cache_exists'] = True 68 | try: 69 | with open(plex_unwatched_user_path, 'r') as f: 70 | data = json.load(f) 71 | stats['plex']['unwatched_count'] = len(data) 72 | except Exception as e: 73 | logger.error(f"Error reading Plex unwatched cache for {username}: {e}") 74 | stats['plex']['unwatched_count'] = 0 75 | stats['plex']['cache_exists'] = False 76 | else: 77 | stats['plex']['cache_exists'] = False 78 | stats['plex']['unwatched_count'] = 0 79 | 80 | global_plex_all_path = self.get_user_cache_path(None, 'plex', 'all') 81 | if os.path.exists(global_plex_all_path): 82 | try: 83 | with open(global_plex_all_path, 'r') as f: 84 | data = json.load(f) 85 | stats['plex']['all_count'] = len(data) 86 | except Exception as e: 87 | logger.error(f"Error reading global Plex all_movies cache for user-stat {username}: {e}") 88 | stats['plex']['all_count'] = 0 89 | else: 90 | stats['plex']['all_count'] = 0 91 | 92 | jellyfin_path = self.get_user_cache_path(username, 'jellyfin') 93 | if os.path.exists(jellyfin_path): 94 | stats['jellyfin']['cache_exists'] = True 95 | try: 96 | with open(jellyfin_path, 'r') as f: 97 | data = json.load(f) 98 | stats['jellyfin']['all_count'] = len(data) 99 | except Exception as e: 100 | logger.error(f"Error reading Jellyfin cache for {username}: {e}") 101 | 102 | emby_path = self.get_user_cache_path(username, 'emby') 103 | if os.path.exists(emby_path): 104 | stats['emby']['cache_exists'] = True 105 | try: 106 | with open(emby_path, 'r') as f: 107 | data = json.load(f) 108 | stats['emby']['all_count'] = len(data) 109 | except Exception as e: 110 | logger.error(f"Error reading Emby cache for {username}: {e}") 111 | 112 | return stats 113 | 114 | def list_cached_users(self): 115 | """List all users who have cache files""" 116 | try: 117 | if not os.path.exists(self.users_data_dir): 118 | return [] 119 | 120 | return [ 121 | dir_name for dir_name in os.listdir(self.users_data_dir) 122 | if os.path.isdir(os.path.join(self.users_data_dir, dir_name)) 123 | ] 124 | except Exception as e: 125 | logger.error(f"Error listing cached users: {e}") 126 | return [] 127 | 128 | def clear_user_cache(self, username, service=None): 129 | """Clear a user's cache files""" 130 | if not username: 131 | return False 132 | 133 | user_dir = os.path.join(self.users_data_dir, username) 134 | if not os.path.exists(user_dir): 135 | return False 136 | 137 | try: 138 | if service: 139 | if service == 'plex': 140 | cache_files = [ 141 | self.get_user_cache_path(username, 'plex', 'unwatched'), 142 | self.get_user_cache_path(username, 'plex', 'all') 143 | ] 144 | elif service in ['jellyfin', 'emby']: 145 | cache_files = [self.get_user_cache_path(username, service)] 146 | 147 | for cache_file in cache_files: 148 | if os.path.exists(cache_file): 149 | os.remove(cache_file) 150 | logger.info(f"Removed cache file {cache_file} for user {username}") 151 | else: 152 | for file_name in os.listdir(user_dir): 153 | if file_name.endswith('.json'): 154 | file_path = os.path.join(user_dir, file_name) 155 | os.remove(file_path) 156 | logger.info(f"Removed cache file {file_path} for user {username}") 157 | 158 | return True 159 | except Exception as e: 160 | logger.error(f"Error clearing cache for user {username}: {e}") 161 | return False 162 | -------------------------------------------------------------------------------- /utils/version.py: -------------------------------------------------------------------------------- 1 | VERSION = "4.1.2" 2 | -------------------------------------------------------------------------------- /utils/youtube_trailer.py: -------------------------------------------------------------------------------- 1 | # youtube_trailer.py 2 | 3 | import requests 4 | 5 | def search_youtube_trailer(movie_title, movie_year): 6 | """Fetch a trailer URL from YouTube using a direct search query.""" 7 | 8 | # Format the movie title for the YouTube search query 9 | formatted_title = f'{movie_title.replace(" ", "+")}+{movie_year}+trailer' 10 | search_url = f'https://www.youtube.com/results?search_query={formatted_title}' 11 | 12 | try: 13 | # Make a request to the YouTube search URL 14 | response = requests.get(search_url) 15 | response.raise_for_status() 16 | 17 | # Check if response contains any trailer link (simplistic approach) 18 | if 'watch?v=' in response.text: 19 | start_index = response.text.find('watch?v=') + 8 20 | end_index = response.text.find('"', start_index) 21 | trailer_id = response.text[start_index:end_index] 22 | trailer_url = f'https://www.youtube.com/embed/{trailer_id}' 23 | return trailer_url 24 | else: 25 | return "Trailer not found on YouTube." 26 | 27 | except requests.RequestException as e: 28 | return f"Error fetching YouTube trailer: {e}" 29 | 30 | -------------------------------------------------------------------------------- /web/plex_auth_success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Authentication Successful 5 | 32 | 33 | 34 |
35 |
36 |

Authentication Successful

37 |

You've successfully authenticated with Plex. This window will close automatically.

38 |
39 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /web/poster.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Now Playing 7 | 8 | 9 | 25 | 26 | 27 | 28 |
29 |
30 |
31 | Start Time 32 | {{ movie.start_time if movie else '' }} 33 |
34 |
NOW PLAYING
35 |
36 | End Time 37 | {{ movie.end_time if movie else '' }} 38 |
39 |
40 |
41 |
42 |
43 | 44 | {{ movie.title if movie else 'Default' }} Poster 45 | 46 |
47 |
{{ custom_text | safe }}
48 |
49 | 50 |
51 |
52 | 53 |
54 |
55 |
56 | 57 | 58 | -------------------------------------------------------------------------------- /web/settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Settings - Movie Roulette 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% if settings_disabled %} 15 | {% if no_services_configured %} 16 |
17 |

Service Configuration Required

18 |

Settings disabled. No media services are configured. Movie Roulette requires either Plex, Jellyfin or Emby to be configured through environment variables:

19 |
20 |

Required environment variables for Plex:

21 |
22 | PLEX_URL=http://your-plex-server:32400
23 | PLEX_TOKEN=your-plex-token
24 | PLEX_MOVIE_LIBRARIES=Movies,Other Movies
25 |

Required environment variables for Jellyfin:

26 |
27 | JELLYFIN_URL=http://your-jellyfin-server:8096
28 | JELLYFIN_API_KEY=your-api-key
29 | JELLYFIN_USER_ID=your-user-id
30 |

Required environment variables for Emby:

31 |
32 | EMBY_URL=http://your-emby-server:8096
33 | EMBY_API_KEY=your-api-key
34 | EMBY_USER_ID=your-user-id
35 |
36 |
37 | {% else %} 38 |
39 |

Settings Disabled

40 |

The settings page has been disabled by the administrator.

41 |

System configuration can only be modified through environment variables.

42 | 43 | 45 | 46 | 47 | 48 | Back to Movies 49 | 50 |
51 | {% endif %} 52 | {% else %} 53 | {% with messages = get_flashed_messages(with_categories=true) %} 54 | {% if messages %} 55 |
56 | {% for category, message in messages %} 57 |
58 |
59 | 60 | {{ message }} 61 |
62 |
63 | {% endfor %} 64 |
65 | {% endif %} 66 | {% endwith %} 67 |
68 | 69 | 70 | 71 | 72 | 73 | {% endif %} 74 | 80 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /web/setup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Setup - Movie Roulette 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 75 | 76 | 107 | 108 | 109 | --------------------------------------------------------------------------------