├── .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 |
170 |
171 | Redirecting...
172 |
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 | Close
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 |
204 |
205 | Redirecting...
206 |
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 |
61 |
62 | Cancel
63 | Add User
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 |
137 |
138 |
139 |
140 |
141 | User
142 |
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 | Refresh Cache
230 | Clear Cache
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 |
43 |
44 |
45 |
46 |
47 |
{{ custom_text | safe }}
48 |
49 |
50 |
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 |
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 |
22 |
23 |
24 |
25 |
Welcome to Movie Roulette
26 |
27 |
28 |
29 |
Authentication has been enabled. Please set the password for the default admin account.
30 |
31 |
32 |
73 |
74 |
75 |
76 |
107 |
108 |
109 |
--------------------------------------------------------------------------------