add provider

This commit is contained in:
Filipinos 2025-07-25 23:57:03 +02:00
parent 98de6ec451
commit 50b9d82f0f
15 changed files with 523 additions and 201 deletions

View File

@ -24,7 +24,7 @@ Get ready to be amazed by everything CinePlex has under the hood. This isn't jus
* **🎬 Pimped-Out Interface:** Forget boring UIs. We use TheMovieDB's API to bring you high-res posters, spectacular backdrops, gripping synopses, ratings, cast info... all the juicy movie gossip you crave! * **🎬 Pimped-Out Interface:** Forget boring UIs. We use TheMovieDB's API to bring you high-res posters, spectacular backdrops, gripping synopses, ratings, cast info... all the juicy movie gossip you crave!
* **🗣️ Multilingual Maestro:** Hola! Bonjour! Hallo! CinePlex now speaks your language with full i18n support. No more getting lost in translation your media, your language! * **🗣️ Multilingual Maestro:** Hola! Bonjour! Hallo! CinePlex now speaks your language with full i18n support. No more getting lost in translation your media, your language!
* **📡 Psychic Plex & Jellyfin Scanner:** You give it your Plex tokens or Jellyfin server details, and CinePlex goes into full detective mode. It scans your servers, figures out what you *actually* have, and jots it down in its secret notebook (a local IndexedDB database in your browser). It's like having a personal librarian for your media! * **📡 Psychic Plex Scanner:** You give it your Plex tokens, and CinePlex goes into full detective mode. It scans your servers, figures out what you *actually* have, and jots it down in its secret notebook (a local IndexedDB database in your browser). It's like having a personal librarian for your media!
* **✅ "Got It" Badge of Honor:** See a movie you want to watch? CinePlex will let you know if you already have it on your server with a neat "Local" badge. No more blind searching! * **✅ "Got It" Badge of Honor:** See a movie you want to watch? CinePlex will let you know if you already have it on your server with a neat "Local" badge. No more blind searching!
* **🎶 Music Jukebox 2077:** It's not all about movies. We've built a full-fledged music player that connects directly to your Plex music library. Browse artists, listen to albums, and rock out with a **graphic equalizer and audio visualizer**! Your personal party, guaranteed! * **🎶 Music Jukebox 2077:** It's not all about movies. We've built a full-fledged music player that connects directly to your Plex music library. Browse artists, listen to albums, and rock out with a **graphic equalizer and audio visualizer**! Your personal party, guaranteed!
* **📊 The Nerd Stats Panel:** Ever wondered how many 80s movies you have? Or what your most common genre is? Dive into the statistics panel and get a full breakdown of your media library with amazing charts. Unleash your inner nerd! * **📊 The Nerd Stats Panel:** Ever wondered how many 80s movies you have? Or what your most common genre is? Dive into the statistics panel and get a full breakdown of your media library with amazing charts. Unleash your inner nerd!
@ -33,7 +33,6 @@ Get ready to be amazed by everything CinePlex has under the hood. This isn't jus
* **🔥 Stream Straight to Your Server:** This is where it gets wild. Configure a simple PHP script on your server, and you can send streams from CinePlex directly to your M3U playlist file with a single click. We even give you a **PHP script generator** to make it foolproof! * **🔥 Stream Straight to Your Server:** This is where it gets wild. Configure a simple PHP script on your server, and you can send streams from CinePlex directly to your M3U playlist file with a single click. We even give you a **PHP script generator** to make it foolproof!
* **❤️ Favorites & Goldfish Memory:** Save your favorite movies and shows. Plus, we've got a "History" section so you can remember what you were watching last night before you fell asleep on the couch. Never lose track again! * **❤️ Favorites & Goldfish Memory:** Save your favorite movies and shows. Plus, we've got a "History" section so you can remember what you were watching last night before you fell asleep on the couch. Never lose track again!
* **🧠 AI-Powered Recommendations:** Based on your viewing history and favorites, CinePlex will suggest new content you might love. It's like having a personal movie critic living in your browser. * **🧠 AI-Powered Recommendations:** Based on your viewing history and favorites, CinePlex will suggest new content you might love. It's like having a personal movie critic living in your browser.
* **🔭 Server Activity Viewer:** Curious about who's watching what on your Plex server? The Activity Viewer gives you a real-time look at active sessions. It's like having your own mission control!
* **🔧 Customization Tuning Shop:** Don't like the dark theme? Switch to light mode! Don't want the giant hero banner? Hide it! Add your own TMDB API key. You're the boss, this is your extension! * **🔧 Customization Tuning Shop:** Don't like the dark theme? Switch to light mode! Don't want the giant hero banner? Hide it! Add your own TMDB API key. You're the boss, this is your extension!
--- ---
@ -42,10 +41,6 @@ Get ready to be amazed by everything CinePlex has under the hood. This isn't jus
Get ready for adventure! Getting this beast up and running is easier than finding popcorn at the movies. Follow these simple steps and you'll be navigating your Plex like a Starship captain. Get ready for adventure! Getting this beast up and running is easier than finding popcorn at the movies. Follow these simple steps and you'll be navigating your Plex like a Starship captain.
## 🛠️ Installation and First Steps: Liftoff in 3, 2, 1...!
Getting this beast up and running is easier than finding popcorn at the movies. Follow these simple steps and you'll be navigating your media universe like a starship captain.
### 1. Installing the Extension: The First Quantum Leap! ### 1. Installing the Extension: The First Quantum Leap!
Since we're not yet on the Chrome Web Store (but we will be, oh yes!), you'll have to load it as an "unpacked" extension. Think of it as an exclusive beta launch just for you. Since we're not yet on the Chrome Web Store (but we will be, oh yes!), you'll have to load it as an "unpacked" extension. Think of it as an exclusive beta launch just for you.
@ -61,27 +56,31 @@ Since we're not yet on the Chrome Web Store (but we will be, oh yes!), you'll ha
When you first open CinePlex, it's like a newly built spaceship: impressive, but it needs fuel and coordinates. Let's bring it to life! When you first open CinePlex, it's like a newly built spaceship: impressive, but it needs fuel and coordinates. Let's bring it to life!
1. **Open CinePlex Settings:** Click the CinePlex icon in your browser's toolbar to open the application in a new tab. Click the **cogwheel icon (⚙️)** in the top-right corner to open the Settings modal. This is where the magic happens! 1. **Find Your Plex Token: The Master Key to the Universe!** This is the MOST important step. You need your `X-Plex-Token` to let CinePlex talk to your Plex server. The easiest way is to follow the official Plex guide: [Finding an Authentication Token / X-Plex-Token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/). Don't share this key with anyone, it's yours and yours alone!
2. **Connect to Your Servers:** CinePlex supports both Plex and Jellyfin. You can use either or both! 2. **Open CinePlex Settings: The Control Panel!**
* Click the CinePlex icon in your browser's toolbar to open the application in a new tab.
* Click the **cogwheel icon (⚙️)** in the top-right corner to open the Settings modal. This is where the magic happens!
* **For Plex:** 3. **Add Your Token: Injecting the Fuel!**
* **Find Your Plex Token:** This is the MOST important step. You need your `X-Plex-Token` to let CinePlex talk to your Plex server. The easiest way is to follow the official Plex guide: [Finding an Authentication Token / X-Plex-Token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/). Don't share this key with anyone, it's yours and yours alone! * Go to the **Plex** tab.
* In CinePlex settings, go to the **Plex** tab. * You'll see a code editor. Paste your `X-Plex-Token` inside the square brackets `[]`. If you have more than one (how lucky!), separate them with commas. It should look something like this:
* You'll see a code editor. Paste your `X-Plex-Token` inside the square brackets `[]`. If you have more than one, separate them with commas. ```json
* Click **"Save Tokens"**. {
"tokens": [
"YourPlexTokenGoesHere_abc123",
"AnotherTokenIfYouHaveOne_def456"
]
}
```
* Click the **"Save Tokens"** button. You've secured the connection!
* **For Jellyfin:** 4. **Start Your First Scan: The Great Exploration!**
* In CinePlex settings, go to the **Jellyfin** tab. * Still in the Plex tab, check the boxes for the content you want to scan (e.g., Movies, Series, Music, Photos).
* Enter your Jellyfin server's URL, username, and password. * Click the big blue **"Start Scan"** button. It's time for CinePlex to discover all your treasures!
* Click **"Connect and Scan"** to test the connection and perform an initial scan. * A console will appear at the bottom of the main page, showing you the scanner's progress. Be patient, the first scan can take a few minutes if you have a gigantic library. Rome wasn't built in a day, and your Plex library won't be scanned in a second either!
3. **Start Your First Scan:** 5. **Enjoy!** Once the scan is complete, the app will automatically refresh. Go back to the main view and start exploring your newly supercharged Plex interface! The galaxy of your content awaits!
* For Plex, go to the **Plex** tab, check the boxes for the content you want to scan (e.g., Movies, Series, Music, Photos), and click **"Start Scan"**.
* For Jellyfin, the initial scan is done when you connect. You can re-scan at any time by clicking the button again.
* A console will appear at the bottom of the main page, showing you the scanner's progress. Be patient, the first scan can take a few minutes if you have a gigantic library.
4. **Enjoy!** Once the scan is complete, the app will automatically refresh. Go back to the main view and start exploring your newly supercharged media interface! The galaxy of your content awaits!
--- ---
@ -98,10 +97,10 @@ Want to take your experience to the next level and use the "Add Stream" button?
--- ---
## 🔒 Permissions: What Does CinePlex Need and Why? ## 🔒 Permissions: What Does CinePlex Need?
CinePlex is designed to be as non-intrusive as possible, but it does need a few permissions to work its magic: CinePlex is designed to be as non-intrusive as possible, but it does need a few permissions to work its magic:
* **`storage`**: This allows CinePlex to store your Plex tokens, Jellyfin server details, scanned library data, and your personalized settings directly in your browser's local storage. All your data stays on your machine, safe and sound! * **`storage`**: This allows CinePlex to store your Plex tokens, scanned library data, and your personalized settings directly in your browser's local storage. All your data stays on your machine, safe and sound!
* **`notifications`**: Used to send you helpful notifications, for example, when a scan is complete or if there's an important update. * **`notifications`**: Used to send you helpful notifications, for example, when a scan is complete or if there's an important update.
* **`host_permissions` for `http://*/*` and `https://*/*`**: This is crucial! It allows CinePlex to communicate directly with your Plex and Jellyfin servers, which could be on any address on your local network (like `http://192.168.1.100:8096`) or on the web. Without this, CinePlex wouldn't be able to see your awesome media collection. * **`host_permissions` for `https://*.plex.tv/*`**: This is crucial! It allows CinePlex to communicate directly with your Plex servers to fetch your library information. Without this, CinePlex wouldn't be able to see your awesome media collection.

View File

@ -9,6 +9,7 @@
"settings": { "message": "Einstellungen" }, "settings": { "message": "Einstellungen" },
"navMovies": { "message": "Filme" }, "navMovies": { "message": "Filme" },
"navSeries": { "message": "Serien" }, "navSeries": { "message": "Serien" },
"navProviders": { "message": "Anbieter" },
"navPhotos": { "message": "Fotos" }, "navPhotos": { "message": "Fotos" },
"navStats": { "message": "Statistiken" }, "navStats": { "message": "Statistiken" },
"navFavorites": { "message": "Favoriten" }, "navFavorites": { "message": "Favoriten" },
@ -65,6 +66,10 @@
"settingsTmdbApiLabel": { "message": "TMDB API-Schlüssel (Optional)" }, "settingsTmdbApiLabel": { "message": "TMDB API-Schlüssel (Optional)" },
"settingsTmdbApiPlaceholder": { "message": "Verwendet den Standardschlüssel, wenn leer gelassen" }, "settingsTmdbApiPlaceholder": { "message": "Verwendet den Standardschlüssel, wenn leer gelassen" },
"settingsTmdbLangLabel": { "message": "Sprache für TMDB & UI" }, "settingsTmdbLangLabel": { "message": "Sprache für TMDB & UI" },
"settingsRegionLabel": { "message": "Region für Anbieter" },
"allRegions": { "message": "Alle Regionen" },
"loadingRegions": { "message": "Regionen werden geladen..." },
"errorLoadingRegions": { "message": "Fehler beim Laden der Regionen" },
"settingsPhpUrlLabel": { "message": "Server-URL zum Hinzufügen von Streams" }, "settingsPhpUrlLabel": { "message": "Server-URL zum Hinzufügen von Streams" },
"settingsPhpUrlPlaceholder": { "message": "https://dein-server.com/pfad/zum/script.php" }, "settingsPhpUrlPlaceholder": { "message": "https://dein-server.com/pfad/zum/script.php" },
"settingsInterface": { "message": "Oberfläche" }, "settingsInterface": { "message": "Oberfläche" },
@ -241,7 +246,7 @@
"shuffleOn": { "message": "Zufallsmodus aktiviert." }, "shuffleOn": { "message": "Zufallsmodus aktiviert." },
"shuffleOff": { "message": "Zufallsmodus deaktiviert." }, "shuffleOff": { "message": "Zufallsmodus deaktiviert." },
"downloadingSong": { "message": "Starte Download von \"$title$\"", "placeholders": { "title": { "content": "$1" } } }, "downloadingSong": { "message": "Starte Download von \"$title$\"", "placeholders": { "title": { "content": "$1" } } },
"songDownloaded": { "message": "\"$title$\" heruntergeladen.", "placeholders": { "title": { "content": "$1" } } }, "songDownloaded": { "message": "\"\"$title$\" heruntergeladen.", "placeholders": { "title": { "content": "$1" } } },
"errorDownloadingSong": { "message": "Fehler beim Herunterladen von \"$title$\"", "placeholders": { "title": { "content": "$1" } } }, "errorDownloadingSong": { "message": "Fehler beim Herunterladen von \"$title$\"", "placeholders": { "title": { "content": "$1" } } },
"generatingAlbumM3U": { "message": "Generiere M3U für \"$artist$\"", "placeholders": { "artist": { "content": "$1" } } }, "generatingAlbumM3U": { "message": "Generiere M3U für \"$artist$\"", "placeholders": { "artist": { "content": "$1" } } },
"albumM3UGenerated": { "message": "M3U für Album \"$artist$\" generiert.", "placeholders": { "artist": { "content": "$1" } } }, "albumM3UGenerated": { "message": "M3U für Album \"$artist$\" generiert.", "placeholders": { "artist": { "content": "$1" } } },
@ -274,13 +279,12 @@
"episodesCount": {"message": "$count$ Episoden", "placeholders": {"count": {"content": "$1"}}}, "episodesCount": {"message": "$count$ Episoden", "placeholders": {"count": {"content": "$1"}}},
"seasonsCount": {"message": "$count$ Staffeln", "placeholders": {"count": {"content": "$1"}}}, "seasonsCount": {"message": "$count$ Staffeln", "placeholders": {"count": {"content": "$1"}}},
"runtimeMinutes": {"message": "$count$ Min.", "placeholders": {"count": {"content": "$1"}}}, "runtimeMinutes": {"message": "$count$ Min.", "placeholders": {"count": {"content": "$1"}}},
"noTrailerFound": {"message": "Für diesen Titel wurde kein Trailer gefunden."}, "noTrailerFound": {"message": "Für diesen Titel wurde kein Trailer gefunden."}, "fatalInitError": {"message": "Fataler Initialisierungsfehler"},
"fatalInitError": {"message": "Fataler Initialisierungsfehler"},
"fatalInitErrorSub": {"message": "Die Anwendung konnte nicht geladen werden."}, "fatalInitErrorSub": {"message": "Die Anwendung konnte nicht geladen werden."},
"invalidStreamInfo": {"message": "Ungültige Information."}, "invalidStreamInfo": {"message": "Ungültige Information."},
"dbUnavailableForStreams": {"message": "Lokale Datenbank nicht verfügbar."}, "dbUnavailableForStreams": {"message": "Lokale Datenbank nicht verfügbar."},
"noPlexServersForStreams": {"message": "Keine Plex-Server."}, "noPlexServersForStreams": {"message": "Keine Plex-Server."},
"notFoundOnServers": {"message": "\"$query$\" auf Servern nicht gefunden.", "placeholders": {"query": {"content": "$1"}}}, "notFoundOnServers": {"message": "\"query$\" auf Servern nicht gefunden.", "placeholders": {"query": {"content": "$1"}}},
"relativeTime_justNow": { "message": "Gerade eben" }, "relativeTime_justNow": { "message": "Gerade eben" },
"relativeTime_minutesAgo": { "message": "Vor $count$ Minuten", "placeholders": { "count": { "content": "$1" } } }, "relativeTime_minutesAgo": { "message": "Vor $count$ Minuten", "placeholders": { "count": { "content": "$1" } } },
"relativeTime_hoursAgo": { "message": "Vor $count$ Stunden", "placeholders": { "count": { "content": "$1" } } }, "relativeTime_hoursAgo": { "message": "Vor $count$ Stunden", "placeholders": { "count": { "content": "$1" } } },
@ -308,8 +312,8 @@
"jellyfinLibraryScanFailed": { "message": "Fehler beim Scannen der Bibliothek '$libraryName.", "placeholders": { "libraryName": { "content": "$1" } } }, "jellyfinLibraryScanFailed": { "message": "Fehler beim Scannen der Bibliothek '$libraryName.", "placeholders": { "libraryName": { "content": "$1" } } },
"jellyfinScanSuccess": { "message": "Jellyfin-Scan abgeschlossen. $movies$ Filme und $series$ Serien hinzugefügt.", "placeholders": { "movies": { "content": "$1" }, "series": { "content": "$2" } } }, "jellyfinScanSuccess": { "message": "Jellyfin-Scan abgeschlossen. $movies$ Filme und $series$ Serien hinzugefügt.", "placeholders": { "movies": { "content": "$1" }, "series": { "content": "$2" } } },
"noJellyfinCredentials": { "message": "Jellyfin-Anmeldeinformationen nicht konfiguriert." }, "noJellyfinCredentials": { "message": "Jellyfin-Anmeldeinformationen nicht konfiguriert." },
"notFoundOnJellyfin": { "message": "\"$query$\" auf Jellyfin nicht gefunden.", "placeholders": { "query": { "content": "$1" } } }, "notFoundOnJellyfin": { "message": "\"query$\" auf Jellyfin nicht gefunden.", "placeholders": { "query": { "content": "$1" } } },
"notFoundOnAnyServer": { "message": "\"$query$\" auf keinem Server gefunden.", "placeholders": { "query": { "content": "$1" } } }, "notFoundOnAnyServer": { "message": "\"query$\" auf keinem Server gefunden.", "placeholders": { "query": { "content": "$1" } } },
"localOnPlex": { "message": "Auf Plex" }, "localOnPlex": { "message": "Auf Plex" },
"searchOnPlex": { "message": "Auf Plex suchen" }, "searchOnPlex": { "message": "Auf Plex suchen" },
"jellyfinTitle": { "message": "Jellyfin-Inhalt" }, "jellyfinTitle": { "message": "Jellyfin-Inhalt" },
@ -327,5 +331,7 @@
"activityCopyID": { "message": "ID kopieren" }, "activityCopyID": { "message": "ID kopieren" },
"activityError": { "message": "Serveraktivität konnte nicht abgerufen werden." }, "activityError": { "message": "Serveraktivität konnte nicht abgerufen werden." },
"activityCopied": { "message": "Kennung in die Zwischenablage kopiert!" }, "activityCopied": { "message": "Kennung in die Zwischenablage kopiert!" },
"activityCopyError": { "message": "Fehler beim Kopieren der Kennung." } "activityCopyError": { "message": "Fehler beim Kopieren der Kennung." },
"noProvidersFound": { "message": "Keine Anbieter gefunden." },
"availableOnPlex": { "message": "Auf Plex verfügbar" }
} }

View File

@ -9,6 +9,7 @@
"settings": { "message": "Settings" }, "settings": { "message": "Settings" },
"navMovies": { "message": "Movies" }, "navMovies": { "message": "Movies" },
"navSeries": { "message": "Series" }, "navSeries": { "message": "Series" },
"navProviders": { "message": "Providers" },
"navPhotos": { "message": "Photos" }, "navPhotos": { "message": "Photos" },
"navStats": { "message": "Statistics" }, "navStats": { "message": "Statistics" },
"navFavorites": { "message": "Favorites" }, "navFavorites": { "message": "Favorites" },
@ -198,7 +199,7 @@
"sendingStreams": { "message": "Sending $count$ stream(s) to the server...", "placeholders": { "count": { "content": "$1" } } }, "sendingStreams": { "message": "Sending $count$ stream(s) to the server...", "placeholders": { "count": { "content": "$1" } } },
"streamAddedSuccess": { "message": "Stream(s) added successfully." }, "streamAddedSuccess": { "message": "Stream(s) added successfully." },
"generatingM3U": { "message": "Generating M3U for \"$title$\"...", "placeholders": { "title": { "content": "$1" } } }, "generatingM3U": { "message": "Generating M3U for \"$title$\"...", "placeholders": { "title": { "content": "$1" } } },
"m3uDownloaded": { "message": "M3U for \"$title$\" downloaded.", "placeholders": { "title": { "content": "$1" } } }, "m3uDownloaded": { "message": "\"$title$\" downloaded.", "placeholders": { "title": { "content": "$1" } } },
"errorGeneratingM3U": { "message": "Error generating M3U: $message$", "placeholders": { "message": { "content": "$1" } } }, "errorGeneratingM3U": { "message": "Error generating M3U: $message$", "placeholders": { "message": { "content": "$1" } } },
"settingsSavedSuccess": { "message": "Settings saved successfully." }, "settingsSavedSuccess": { "message": "Settings saved successfully." },
"errorSavingSettings": { "message": "Error saving settings to the database." }, "errorSavingSettings": { "message": "Error saving settings to the database." },
@ -305,7 +306,7 @@
"jellyfinNoMediaLibraries": { "message": "No movie or series libraries found on Jellyfin." }, "jellyfinNoMediaLibraries": { "message": "No movie or series libraries found on Jellyfin." },
"jellyfinLibrariesFound": { "message": "$count$ media library(s) found.", "placeholders": { "count": { "content": "$1" } } }, "jellyfinLibrariesFound": { "message": "$count$ media library(s) found.", "placeholders": { "count": { "content": "$1" } } },
"jellyfinLibraryScanSuccess": { "message": "[Success] Scanned '$libraryName, added $count$ titles.", "placeholders": { "libraryName": { "content": "$1" }, "count": { "content": "$2" } } }, "jellyfinLibraryScanSuccess": { "message": "[Success] Scanned '$libraryName, added $count$ titles.", "placeholders": { "libraryName": { "content": "$1" }, "count": { "content": "$2" } } },
"jellyfinLibraryScanFailed": { "message": "Failed to scan library '$libraryName." , "placeholders": { "libraryName": { "content": "$1" } } }, "jellyfinLibraryScanFailed": { "message": "Failed to scan library '$libraryName.", "placeholders": { "libraryName": { "content": "$1" } } },
"jellyfinScanSuccess": { "message": "Jellyfin scan completed. Added $movies$ movies and $series$ series.", "placeholders": { "movies": { "content": "$1" }, "series": { "content": "$2" } } }, "jellyfinScanSuccess": { "message": "Jellyfin scan completed. Added $movies$ movies and $series$ series.", "placeholders": { "movies": { "content": "$1" }, "series": { "content": "$2" } } },
"noJellyfinCredentials": { "message": "Jellyfin credentials not configured." }, "noJellyfinCredentials": { "message": "Jellyfin credentials not configured." },
"notFoundOnJellyfin": { "message": "\"$query$\" not found on Jellyfin.", "placeholders": { "query": { "content": "$1" } } }, "notFoundOnJellyfin": { "message": "\"$query$\" not found on Jellyfin.", "placeholders": { "query": { "content": "$1" } } },
@ -327,5 +328,7 @@
"activityCopyID": { "message": "Copy ID" }, "activityCopyID": { "message": "Copy ID" },
"activityError": { "message": "Could not fetch server activity." }, "activityError": { "message": "Could not fetch server activity." },
"activityCopied": { "message": "Identifier copied to clipboard!" }, "activityCopied": { "message": "Identifier copied to clipboard!" },
"activityCopyError": { "message": "Failed to copy identifier." } "activityCopyError": { "message": "Failed to copy identifier." },
"noProvidersFound": { "message": "No providers found." },
"availableOnPlex": { "message": "Available on Plex" }
} }

View File

@ -9,6 +9,7 @@
"settings": { "message": "Ajustes" }, "settings": { "message": "Ajustes" },
"navMovies": { "message": "Películas" }, "navMovies": { "message": "Películas" },
"navSeries": { "message": "Series" }, "navSeries": { "message": "Series" },
"navProviders": { "message": "Proveedores" },
"navPhotos": { "message": "Fotos" }, "navPhotos": { "message": "Fotos" },
"navStats": { "message": "Estadísticas" }, "navStats": { "message": "Estadísticas" },
"navFavorites": { "message": "Favoritos" }, "navFavorites": { "message": "Favoritos" },
@ -198,7 +199,7 @@
"sendingStreams": { "message": "Enviando $count$ stream(s) al servidor...", "placeholders": { "count": { "content": "$1" } } }, "sendingStreams": { "message": "Enviando $count$ stream(s) al servidor...", "placeholders": { "count": { "content": "$1" } } },
"streamAddedSuccess": { "message": "Stream(s) añadido(s) con éxito." }, "streamAddedSuccess": { "message": "Stream(s) añadido(s) con éxito." },
"generatingM3U": { "message": "Generando M3U para \"$title$\"", "placeholders": { "title": { "content": "$1" } } }, "generatingM3U": { "message": "Generando M3U para \"$title$\"", "placeholders": { "title": { "content": "$1" } } },
"m3uDownloaded": { "message": "M3U para \"$title$\" descargado.", "placeholders": { "title": { "content": "$1" } } }, "m3uDownloaded": { "message": "\"\"$title$\" descargado.", "placeholders": { "title": { "content": "$1" } } },
"errorGeneratingM3U": { "message": "Error al generar M3U: $message$", "placeholders": { "message": { "content": "$1" } } }, "errorGeneratingM3U": { "message": "Error al generar M3U: $message$", "placeholders": { "message": { "content": "$1" } } },
"settingsSavedSuccess": { "message": "Ajustes guardados correctamente." }, "settingsSavedSuccess": { "message": "Ajustes guardados correctamente." },
"errorSavingSettings": { "message": "Error al guardar los ajustes en la base de datos." }, "errorSavingSettings": { "message": "Error al guardar los ajustes en la base de datos." },
@ -327,5 +328,7 @@
"activityCopyID": { "message": "Copiar ID" }, "activityCopyID": { "message": "Copiar ID" },
"activityError": { "message": "No se pudo obtener la actividad del servidor." }, "activityError": { "message": "No se pudo obtener la actividad del servidor." },
"activityCopied": { "message": "¡Identificador copiado al portapapeles!" }, "activityCopied": { "message": "¡Identificador copiado al portapapeles!" },
"activityCopyError": { "message": "Error al copiar el identificador." } "activityCopyError": { "message": "Error al copiar el identificador." },
"noProvidersFound": { "message": "No se encontraron proveedores." },
"availableOnPlex": { "message": "Disponible en Plex" }
} }

View File

@ -9,6 +9,7 @@
"settings": { "message": "Paramètres" }, "settings": { "message": "Paramètres" },
"navMovies": { "message": "Films" }, "navMovies": { "message": "Films" },
"navSeries": { "message": "Séries" }, "navSeries": { "message": "Séries" },
"navProviders": { "message": "Fournisseurs" },
"navPhotos": { "message": "Photos" }, "navPhotos": { "message": "Photos" },
"navStats": { "message": "Statistiques" }, "navStats": { "message": "Statistiques" },
"navFavorites": { "message": "Favoris" }, "navFavorites": { "message": "Favoris" },
@ -194,10 +195,10 @@
"errorLoadingActorContent": { "message": "Impossible de charger le contenu pour $actorName$.", "placeholders": { "actorName": { "content": "$1" } } }, "errorLoadingActorContent": { "message": "Impossible de charger le contenu pour $actorName$.", "placeholders": { "actorName": { "content": "$1" } } },
"errorAddingStream": { "message": "Erreur lors de l'ajout du ou des flux : $message$", "placeholders": { "message": { "content": "$1" } } }, "errorAddingStream": { "message": "Erreur lors de l'ajout du ou des flux : $message$", "placeholders": { "message": { "content": "$1" } } },
"phpUrlNotConfigured": { "message": "L'URL du serveur PHP n'est pas configurée. Veuillez la configurer dans les Paramètres." }, "phpUrlNotConfigured": { "message": "L'URL du serveur PHP n'est pas configurée. Veuillez la configurer dans les Paramètres." },
"searchingStreams": { "message": "Recherche de flux pour \"$title$\"...", "placeholders": { "title": { "content": "$1" } } }, "searchingStreams": { "message": "Recherche de flux pour \"$title$\"", "placeholders": { "title": { "content": "$1" } } },
"sendingStreams": { "message": "Envoi de $count$ flux au serveur...", "placeholders": { "count": { "content": "$1" } } }, "sendingStreams": { "message": "Envoi de $count$ flux au serveur...", "placeholders": { "count": { "content": "$1" } } },
"streamAddedSuccess": { "message": "Flux ajouté(s) avec succès." }, "streamAddedSuccess": { "message": "Flux ajouté(s) avec succès." },
"generatingM3U": { "message": "Génération du M3U pour \"$title$\"...", "placeholders": { "title": { "content": "$1" } } }, "generatingM3U": { "message": "Génération du M3U pour \"$title$\"", "placeholders": { "title": { "content": "$1" } } },
"m3uDownloaded": { "message": "M3U pour \"$title$\" téléchargé.", "placeholders": { "title": { "content": "$1" } } }, "m3uDownloaded": { "message": "M3U pour \"$title$\" téléchargé.", "placeholders": { "title": { "content": "$1" } } },
"errorGeneratingM3U": { "message": "Erreur lors de la génération du M3U : $message$", "placeholders": { "message": { "content": "$1" } } }, "errorGeneratingM3U": { "message": "Erreur lors de la génération du M3U : $message$", "placeholders": { "message": { "content": "$1" } } },
"settingsSavedSuccess": { "message": "Paramètres sauvegardés avec succès." }, "settingsSavedSuccess": { "message": "Paramètres sauvegardés avec succès." },
@ -219,7 +220,7 @@
"errorDuringScan": { "message": "Erreur pendant le scan : $message$", "placeholders": { "message": { "content": "$1" } } }, "errorDuringScan": { "message": "Erreur pendant le scan : $message$", "placeholders": { "message": { "content": "$1" } } },
"scanCancelled": { "message": "Scan annulé par l'utilisateur." }, "scanCancelled": { "message": "Scan annulé par l'utilisateur." },
"scanCancelledInfo": { "message": "Scan annulé." }, "scanCancelledInfo": { "message": "Scan annulé." },
"retyingSection": { "message": "Nouvelle tentative pour la section \"$title$\"...", "placeholders": { "title": { "content": "$1" } } }, "retyingSection": { "message": "Nouvelle tentative pour la section \"$title$\"", "placeholders": { "title": { "content": "$1" } } },
"retrySuccess": { "message": "[SUCCÈS] Relance de \"$title$\" terminée.", "placeholders": { "title": { "content": "$1" } } }, "retrySuccess": { "message": "[SUCCÈS] Relance de \"$title$\" terminée.", "placeholders": { "title": { "content": "$1" } } },
"retryError": { "message": "[ERREUR FINALE] Échec de la nouvelle tentative pour \"$title$\" : $message$", "placeholders": { "title": { "content": "$1" }, "message": { "content": "$2" } } }, "retryError": { "message": "[ERREUR FINALE] Échec de la nouvelle tentative pour \"$title$\" : $message$", "placeholders": { "title": { "content": "$1" }, "message": { "content": "$2" } } },
"noRetriesPending": { "message": "Aucune relance en attente." }, "noRetriesPending": { "message": "Aucune relance en attente." },
@ -240,10 +241,10 @@
"noSongsFound": { "message": "Aucune chanson trouvée." }, "noSongsFound": { "message": "Aucune chanson trouvée." },
"shuffleOn": { "message": "Mode aléatoire activé." }, "shuffleOn": { "message": "Mode aléatoire activé." },
"shuffleOff": { "message": "Mode aléatoire désactivé." }, "shuffleOff": { "message": "Mode aléatoire désactivé." },
"downloadingSong": { "message": "Début du téléchargement de \"$title$\"...", "placeholders": { "title": { "content": "$1" } } }, "downloadingSong": { "message": "Début du téléchargement de \"$title$\"", "placeholders": { "title": { "content": "$1" } } },
"songDownloaded": { "message": "\"$title$\" téléchargé.", "placeholders": { "title": { "content": "$1" } } }, "songDownloaded": { "message": "\"\"$title$\" téléchargé.", "placeholders": { "title": { "content": "$1" } } },
"errorDownloadingSong": { "message": "Erreur lors du téléchargement de \"$title$\"", "placeholders": { "title": { "content": "$1" } } }, "errorDownloadingSong": { "message": "Erreur lors du téléchargement de \"$title$\"", "placeholders": { "title": { "content": "$1" } } },
"generatingAlbumM3U": { "message": "Génération du M3U pour \"$artist$\"...", "placeholders": { "artist": { "content": "$1" } } }, "generatingAlbumM3U": { "message": "Génération du M3U pour \"$artist$\"", "placeholders": { "artist": { "content": "$1" } } },
"albumM3UGenerated": { "message": "M3U de l'album \"$artist$\" généré.", "placeholders": { "artist": { "content": "$1" } } }, "albumM3UGenerated": { "message": "M3U de l'album \"$artist$\" généré.", "placeholders": { "artist": { "content": "$1" } } },
"playbackError": { "message": "Erreur de lecture" }, "playbackError": { "message": "Erreur de lecture" },
"errorLabel": { "message": "Erreur" }, "errorLabel": { "message": "Erreur" },
@ -280,7 +281,7 @@
"invalidStreamInfo": {"message": "Information invalide."}, "invalidStreamInfo": {"message": "Information invalide."},
"dbUnavailableForStreams": {"message": "Base de données locale non disponible."}, "dbUnavailableForStreams": {"message": "Base de données locale non disponible."},
"noPlexServersForStreams": {"message": "Pas de serveurs Plex."}, "noPlexServersForStreams": {"message": "Pas de serveurs Plex."},
"notFoundOnServers": {"message": "\"$query$\" non trouvé sur les serveurs.", "placeholders": {"query": {"content": "$1"}}}, "notFoundOnServers": {"message": "\"query$\" non trouvé sur les serveurs.", "placeholders": {"query": {"content": "$1"}}},
"relativeTime_justNow": { "message": "À l'instant" }, "relativeTime_justNow": { "message": "À l'instant" },
"relativeTime_minutesAgo": { "message": "Il y a $count$ minutes", "placeholders": { "count": { "content": "$1" } } }, "relativeTime_minutesAgo": { "message": "Il y a $count$ minutes", "placeholders": { "count": { "content": "$1" } } },
"relativeTime_hoursAgo": { "message": "Il y a $count$ heures", "placeholders": { "count": { "content": "$1" } } }, "relativeTime_hoursAgo": { "message": "Il y a $count$ heures", "placeholders": { "count": { "content": "$1" } } },
@ -308,8 +309,8 @@
"jellyfinLibraryScanFailed": { "message": "Échec du scan de la bibliothèque '$libraryName.", "placeholders": { "libraryName": { "content": "$1" } } }, "jellyfinLibraryScanFailed": { "message": "Échec du scan de la bibliothèque '$libraryName.", "placeholders": { "libraryName": { "content": "$1" } } },
"jellyfinScanSuccess": { "message": "Scan Jellyfin terminé. $movies$ films et $series$ séries ajoutés.", "placeholders": { "movies": { "content": "$1" }, "series": { "content": "$2" } } }, "jellyfinScanSuccess": { "message": "Scan Jellyfin terminé. $movies$ films et $series$ séries ajoutés.", "placeholders": { "movies": { "content": "$1" }, "series": { "content": "$2" } } },
"noJellyfinCredentials": { "message": "Identifiants Jellyfin non configurés." }, "noJellyfinCredentials": { "message": "Identifiants Jellyfin non configurés." },
"notFoundOnJellyfin": { "message": "\"$query$\" non trouvé sur Jellyfin.", "placeholders": { "query": { "content": "$1" } } }, "notFoundOnJellyfin": { "message": "\"query$\" non trouvé sur Jellyfin.", "placeholders": { "query": { "content": "$1" } } },
"notFoundOnAnyServer": { "message": "\"$query$\" non trouvé sur aucun serveur.", "placeholders": { "query": { "content": "$1" } } }, "notFoundOnAnyServer": { "message": "\"query$\" non trouvé sur aucun serveur.", "placeholders": { "query": { "content": "$1" } } },
"localOnPlex": { "message": "Sur Plex" }, "localOnPlex": { "message": "Sur Plex" },
"searchOnPlex": { "message": "Rechercher sur Plex" }, "searchOnPlex": { "message": "Rechercher sur Plex" },
"jellyfinTitle": { "message": "Contenu Jellyfin" }, "jellyfinTitle": { "message": "Contenu Jellyfin" },
@ -327,5 +328,7 @@
"activityCopyID": { "message": "Copier l'ID" }, "activityCopyID": { "message": "Copier l'ID" },
"activityError": { "message": "Impossible de récupérer l'activité du serveur." }, "activityError": { "message": "Impossible de récupérer l'activité du serveur." },
"activityCopied": { "message": "Identifiant copié dans le presse-papiers !" }, "activityCopied": { "message": "Identifiant copié dans le presse-papiers !" },
"activityCopyError": { "message": "Échec de la copie de l'identifiant." } "activityCopyError": { "message": "Échec de la copie de l'identifiant." },
"noProvidersFound": { "message": "Aucun fournisseur trouvé." },
"availableOnPlex": { "message": "Disponible sur Plex" }
} }

View File

@ -9,6 +9,7 @@
"settings": { "message": "Impostazioni" }, "settings": { "message": "Impostazioni" },
"navMovies": { "message": "Film" }, "navMovies": { "message": "Film" },
"navSeries": { "message": "Serie TV" }, "navSeries": { "message": "Serie TV" },
"navProviders": { "message": "Fornitori" },
"navPhotos": { "message": "Foto" }, "navPhotos": { "message": "Foto" },
"navStats": { "message": "Statistiche" }, "navStats": { "message": "Statistiche" },
"navFavorites": { "message": "Preferiti" }, "navFavorites": { "message": "Preferiti" },
@ -296,7 +297,7 @@
"noPhotoServers": { "message": "Nessun server di foto" }, "noPhotoServers": { "message": "Nessun server di foto" },
"jellyfinScanInProgress": { "message": "Scansione Jellyfin già in corso." }, "jellyfinScanInProgress": { "message": "Scansione Jellyfin già in corso." },
"jellyfinScanning": { "message": "Scansione di Jellyfin in corso..." }, "jellyfinScanning": { "message": "Scansione di Jellyfin in corso..." },
"jellyfinMissingCredentials": { "message": "Per favore, completa l'URL e il nome utente di Jellyfin." }, "jellyfinMissingCredentials": { "message": "Per favor, completa l'URL e il nome utente di Jellyfin." },
"jellyfinConnecting": { "message": "Connessione a Jellyfin a: $url$", "placeholders": { "url": { "content": "$1" } } }, "jellyfinConnecting": { "message": "Connessione a Jellyfin a: $url$", "placeholders": { "url": { "content": "$1" } } },
"jellyfinAuthFailed": { "message": "Autenticazione Jellyfin fallita: $message$", "placeholders": { "message": { "content": "$1" } } }, "jellyfinAuthFailed": { "message": "Autenticazione Jellyfin fallita: $message$", "placeholders": { "message": { "content": "$1" } } },
"jellyfinAuthSuccess": { "message": "Autenticazione Jellyfin riuscita." }, "jellyfinAuthSuccess": { "message": "Autenticazione Jellyfin riuscita." },
@ -327,5 +328,7 @@
"activityCopyID": { "message": "Copia ID" }, "activityCopyID": { "message": "Copia ID" },
"activityError": { "message": "Impossibile recuperare l'attività del server." }, "activityError": { "message": "Impossibile recuperare l'attività del server." },
"activityCopied": { "message": "Identificatore copiato negli appunti!" }, "activityCopied": { "message": "Identificatore copiato negli appunti!" },
"activityCopyError": { "message": "Copia dell'identificatore non riuscita." } "activityCopyError": { "message": "Copia dell'identificatore non riuscita." },
"noProvidersFound": { "message": "Nessun fornitore trovato." },
"availableOnPlex": { "message": "Disponibile su Plex" }
} }

View File

@ -9,6 +9,7 @@
"settings": { "message": "Configurações" }, "settings": { "message": "Configurações" },
"navMovies": { "message": "Filmes" }, "navMovies": { "message": "Filmes" },
"navSeries": { "message": "Séries" }, "navSeries": { "message": "Séries" },
"navProviders": { "message": "Provedores" },
"navPhotos": { "message": "Fotos" }, "navPhotos": { "message": "Fotos" },
"navStats": { "message": "Estatísticas" }, "navStats": { "message": "Estatísticas" },
"navFavorites": { "message": "Favoritos" }, "navFavorites": { "message": "Favoritos" },
@ -194,10 +195,10 @@
"errorLoadingActorContent": { "message": "Não foi possível carregar o conteúdo de $actorName$.", "placeholders": { "actorName": { "content": "$1" } } }, "errorLoadingActorContent": { "message": "Não foi possível carregar o conteúdo de $actorName$.", "placeholders": { "actorName": { "content": "$1" } } },
"errorAddingStream": { "message": "Erro ao adicionar stream(s): $message$", "placeholders": { "message": { "content": "$1" } } }, "errorAddingStream": { "message": "Erro ao adicionar stream(s): $message$", "placeholders": { "message": { "content": "$1" } } },
"phpUrlNotConfigured": { "message": "A URL do servidor PHP não está configurada. Por favor, configure-a nas Configurações." }, "phpUrlNotConfigured": { "message": "A URL do servidor PHP não está configurada. Por favor, configure-a nas Configurações." },
"searchingStreams": { "message": "Buscando streams para \"$title$\"...", "placeholders": { "title": { "content": "$1" } } }, "searchingStreams": { "message": "Buscando streams para \"$title$\"", "placeholders": { "title": { "content": "$1" } } },
"sendingStreams": { "message": "Enviando $count$ stream(s) para o servidor...", "placeholders": { "count": { "content": "$1" } } }, "sendingStreams": { "message": "Enviando $count$ stream(s) para o servidor...", "placeholders": { "count": { "content": "$1" } } },
"streamAddedSuccess": { "message": "Stream(s) adicionado(s) com sucesso." }, "streamAddedSuccess": { "message": "Stream(s) adicionado(s) com sucesso." },
"generatingM3U": { "message": "Gerando M3U para \"$title$\"...", "placeholders": { "title": { "content": "$1" } } }, "generatingM3U": { "message": "Gerando M3U para \"$title$\"", "placeholders": { "title": { "content": "$1" } } },
"m3uDownloaded": { "message": "M3U para \"$title$\" baixado.", "placeholders": { "title": { "content": "$1" } } }, "m3uDownloaded": { "message": "M3U para \"$title$\" baixado.", "placeholders": { "title": { "content": "$1" } } },
"errorGeneratingM3U": { "message": "Erro ao gerar M3U: $message$", "placeholders": { "message": { "content": "$1" } } }, "errorGeneratingM3U": { "message": "Erro ao gerar M3U: $message$", "placeholders": { "message": { "content": "$1" } } },
"settingsSavedSuccess": { "message": "Configurações salvas com sucesso." }, "settingsSavedSuccess": { "message": "Configurações salvas com sucesso." },
@ -219,7 +220,7 @@
"errorDuringScan": { "message": "Erro durante a análise: $message$", "placeholders": { "message": { "content": "$1" } } }, "errorDuringScan": { "message": "Erro durante a análise: $message$", "placeholders": { "message": { "content": "$1" } } },
"scanCancelled": { "message": "Análise cancelada pelo usuário." }, "scanCancelled": { "message": "Análise cancelada pelo usuário." },
"scanCancelledInfo": { "message": "Análise cancelada." }, "scanCancelledInfo": { "message": "Análise cancelada." },
"retyingSection": { "message": "Tentando novamente a seção \"$title$\"...", "placeholders": { "title": { "content": "$1" } } }, "retyingSection": { "message": "Tentando novamente a seção \"$title$\"", "placeholders": { "title": { "content": "$1" } } },
"retrySuccess": { "message": "[SUCESSO] Nova tentativa de \"$title$\" concluída.", "placeholders": { "title": { "content": "$1" } } }, "retrySuccess": { "message": "[SUCESSO] Nova tentativa de \"$title$\" concluída.", "placeholders": { "title": { "content": "$1" } } },
"retryError": { "message": "[ERRO FINAL] A nova tentativa falhou para \"$title$\": $message$", "placeholders": { "title": { "content": "$1" }, "message": { "content": "$2" } } }, "retryError": { "message": "[ERRO FINAL] A nova tentativa falhou para \"$title$\": $message$", "placeholders": { "title": { "content": "$1" }, "message": { "content": "$2" } } },
"noRetriesPending": { "message": "Nenhuma nova tentativa pendente." }, "noRetriesPending": { "message": "Nenhuma nova tentativa pendente." },
@ -240,10 +241,10 @@
"noSongsFound": { "message": "Nenhuma música encontrada." }, "noSongsFound": { "message": "Nenhuma música encontrada." },
"shuffleOn": { "message": "Modo aleatório ativado." }, "shuffleOn": { "message": "Modo aleatório ativado." },
"shuffleOff": { "message": "Modo aleatório desativado." }, "shuffleOff": { "message": "Modo aleatório desativado." },
"downloadingSong": { "message": "Iniciando download de \"$title$\"...", "placeholders": { "title": { "content": "$1" } } }, "downloadingSong": { "message": "Iniciando download de \"$title$\"", "placeholders": { "title": { "content": "$1" } } },
"songDownloaded": { "message": "\"$title$\" baixado.", "placeholders": { "title": { "content": "$1" } } }, "songDownloaded": { "message": "\"$title$\" baixado.", "placeholders": { "title": { "content": "$1" } } },
"errorDownloadingSong": { "message": "Erro ao baixar \"$title$\"", "placeholders": { "title": { "content": "$1" } } }, "errorDownloadingSong": { "message": "Erro ao baixar \"$title$\"", "placeholders": { "title": { "content": "$1" } } },
"generatingAlbumM3U": { "message": "Gerando M3U para \"$artist$\"...", "placeholders": { "artist": { "content": "$1" } } }, "generatingAlbumM3U": { "message": "Gerando M3U para \"$artist$\"", "placeholders": { "artist": { "content": "$1" } } },
"albumM3UGenerated": { "message": "M3U para o álbum \"$artist$\" gerado.", "placeholders": { "artist": { "content": "$1" } } }, "albumM3UGenerated": { "message": "M3U para o álbum \"$artist$\" gerado.", "placeholders": { "artist": { "content": "$1" } } },
"playbackError": { "message": "Erro de reprodução" }, "playbackError": { "message": "Erro de reprodução" },
"errorLabel": { "message": "Erro" }, "errorLabel": { "message": "Erro" },
@ -327,5 +328,7 @@
"activityCopyID": { "message": "Copiar ID" }, "activityCopyID": { "message": "Copiar ID" },
"activityError": { "message": "Não foi possível buscar a atividade do servidor." }, "activityError": { "message": "Não foi possível buscar a atividade do servidor." },
"activityCopied": { "message": "Identificador copiado para a área de transferência!" }, "activityCopied": { "message": "Identificador copiado para a área de transferência!" },
"activityCopyError": { "message": "Falha ao copiar o identificador." } "activityCopyError": { "message": "Falha ao copiar o identificador." },
"noProvidersFound": { "message": "Nenhum provedor encontrado." },
"availableOnPlex": { "message": "Disponível no Plex" }
} }

90
css/providers.css Normal file
View File

@ -0,0 +1,90 @@
.providers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 30px;
padding: 30px;
perspective: 1000px;
}
.provider-card {
position: relative;
width: 140px;
height: 140px;
background-color: transparent;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.4s ease, box-shadow 0.4s ease;
box-shadow: 0 0 0px rgba(0, 0, 0, 0.3);
overflow: hidden;
border: none;
}
.provider-card:hover {
transform: scale(1.1) translateY(-5px);
box-shadow: 0 0 25px rgba(0, 224, 255, 0.7);
}
.provider-logo {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0;
transition: filter 0.3s ease;
}
.provider-card:hover .provider-logo {
filter: brightness(1.1);
}
.provider-name {
display: none;
}
.provider-card.available {
box-shadow: 0 0 15px #00e0ff;
}
.provider-card.available::after {
content: '\f00c';
font-family: 'Font Awesome 5 Free';
font-weight: 900;
position: absolute;
top: -5px;
right: -5px;
background-color: #00e0ff;
color: #121212;
border-radius: 50%;
width: 25px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
box-shadow: 0 0 10px rgba(0, 224, 255, 0.5);
z-index: 11;
}
.provider-tooltip {
position: absolute;
bottom: -35px;
background-color: #00e0ff;
color: #121212;
padding: 5px 12px;
border-radius: 15px;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.3s ease, transform 0.3s ease;
pointer-events: none;
z-index: 10;
}
.provider-card:hover .provider-tooltip {
opacity: 1;
transform: translateY(0);
}

View File

@ -4,8 +4,7 @@ import { fetchWithTimeout } from './utils.js';
import { getFromDB } from './db.js'; import { getFromDB } from './db.js';
import { _ } from './utils.js'; import { _ } from './utils.js';
export async function fetchTMDB(endpoint, signal) { export async function fetchTMDB(endpoint, params = {}, signal) {
let tmdbLang = 'en-US';
const langMap = { const langMap = {
'es': 'es-ES', 'es': 'es-ES',
'en': 'en-US', 'en': 'en-US',
@ -14,13 +13,26 @@ export async function fetchTMDB(endpoint, signal) {
'it': 'it-IT', 'it': 'it-IT',
'pt': 'pt-BR' 'pt': 'pt-BR'
}; };
const tmdbLang = langMap[state.settings.language] || 'en-US';
if (langMap[state.settings.language]) { const [path, existingQuery] = endpoint.split('?');
tmdbLang = langMap[state.settings.language]; const finalParams = new URLSearchParams(existingQuery);
finalParams.set('api_key', state.settings.apiKey);
finalParams.set('language', tmdbLang);
for (const [key, value] of Object.entries(params)) {
if (value) {
finalParams.set(key, value);
}
} }
const separator = endpoint.includes('?') ? '&' : '?'; if (state.settings.watchRegion && !finalParams.has('watch_region')) {
const url = `https://api.themoviedb.org/3/${endpoint}${separator}language=${tmdbLang}&api_key=${state.settings.apiKey}`; finalParams.set('watch_region', state.settings.watchRegion);
}
const url = `https://api.themoviedb.org/3/${path}?${finalParams.toString()}`;
const response = await fetch(url, { signal }); const response = await fetch(url, { signal });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({ status_message: "Unknown error" })); const errorData = await response.json().catch(() => ({ status_message: "Unknown error" }));

View File

@ -1,5 +1,6 @@
import { state } from './state.js'; import { state } from './state.js';
import { switchView, resetView, showMainView, showItemDetails, applyFilters, searchByActor, loadContent, toggleFavorite, addStreamToList, downloadM3U, showTrailer, closeTrailer, openSettingsModal, saveSettings, updateSectionTitle, generateStatistics, loadFavorites, loadLocalContent, phpScriptGenerator, initPhotosView, handlePhotoGridClick, handlePhotoTokenChange, showNextPhoto, showPrevPhoto, closePhotoLightbox, activateSettingsTab, deleteHistoryItem, clearAllHistory, getTrailerKey, initializeHeroSection } from './ui.js'; import { switchView, resetView, showMainView, showItemDetails, applyFilters, searchByActor, loadContent, toggleFavorite, addStreamToList, downloadM3U, showTrailer, closeTrailer, openSettingsModal, saveSettings, updateSectionTitle, generateStatistics, loadFavorites, loadLocalContent, phpScriptGenerator, initPhotosView, handlePhotoGridClick, handlePhotoTokenChange, showNextPhoto, showPrevPhoto, closePhotoLightbox, activateSettingsTab, deleteHistoryItem, clearAllHistory, getTrailerKey, initializeHeroSection } from './ui.js';
import { loadProviderContent, changeProviderPage, backToProviders } from './providers.js';
import { debounce, showNotification, _ } from './utils.js'; import { debounce, showNotification, _ } from './utils.js';
import { clearContentData, loadTokensToEditor, saveTokensFromEditor, exportDatabase, importDatabase } from './db.js'; import { clearContentData, loadTokensToEditor, saveTokensFromEditor, exportDatabase, importDatabase } from './db.js';
import { startPlexScan } from './plex.js'; import { startPlexScan } from './plex.js';
@ -46,6 +47,7 @@ export function setupEventListeners() {
document.getElementById('nav-movies').addEventListener('click', (e) => { e.preventDefault(); switchView('movies'); }); document.getElementById('nav-movies').addEventListener('click', (e) => { e.preventDefault(); switchView('movies'); });
document.getElementById('nav-series').addEventListener('click', (e) => { e.preventDefault(); switchView('series'); }); document.getElementById('nav-series').addEventListener('click', (e) => { e.preventDefault(); switchView('series'); });
document.getElementById('nav-providers').addEventListener('click', (e) => { e.preventDefault(); switchView('providers'); });
document.getElementById('nav-photos').addEventListener('click', (e) => { e.preventDefault(); switchView('photos'); }); document.getElementById('nav-photos').addEventListener('click', (e) => { e.preventDefault(); switchView('photos'); });
document.getElementById('nav-stats').addEventListener('click', (e) => { e.preventDefault(); switchView('stats'); }); document.getElementById('nav-stats').addEventListener('click', (e) => { e.preventDefault(); switchView('stats'); });
document.getElementById('nav-favorites').addEventListener('click', (e) => { e.preventDefault(); switchView('favorites'); }); document.getElementById('nav-favorites').addEventListener('click', (e) => { e.preventDefault(); switchView('favorites'); });
@ -87,6 +89,20 @@ export function setupEventListeners() {
document.getElementById('sort-filter').addEventListener('change', applyFilters); document.getElementById('sort-filter').addEventListener('change', applyFilters);
document.getElementById('stats-token-filter').addEventListener('change', generateStatistics); document.getElementById('stats-token-filter').addEventListener('change', generateStatistics);
document.getElementById('photos-token-select').addEventListener('change', handlePhotoTokenChange); document.getElementById('photos-token-select').addEventListener('change', handlePhotoTokenChange);
document.getElementById('region-filter').addEventListener('change', saveSettings);
document.getElementById('providers-grid').addEventListener('click', (e) => {
const providerCard = e.target.closest('.provider-card');
if (providerCard) {
const providerId = providerCard.dataset.providerId;
const providerName = providerCard.dataset.providerName;
loadProviderContent(providerId, providerName, 1);
}
});
document.getElementById('back-to-providers').addEventListener('click', backToProviders);
document.getElementById('prev-page').addEventListener('click', () => changeProviderPage(-1));
document.getElementById('next-page').addEventListener('click', () => changeProviderPage(1));
document.querySelector('.back-button').addEventListener('click', showMainView); document.querySelector('.back-button').addEventListener('click', showMainView);
@ -161,7 +177,6 @@ export function setupEventListeners() {
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
// Check if hero section is active but background is gone
const heroSection = document.getElementById('hero-section'); const heroSection = document.getElementById('hero-section');
const bg1 = document.querySelector('.hero-background-1'); const bg1 = document.querySelector('.hero-background-1');
if (heroSection && heroSection.style.display !== 'none' && state.currentView === 'home' && state.heroIntervalId) { if (heroSection && heroSection.style.display !== 'none' && state.currentView === 'home' && state.heroIntervalId) {
@ -178,7 +193,7 @@ export function setupEventListeners() {
window.addEventListener('indexedDBUpdated', handleDatabaseUpdate); window.addEventListener('indexedDBUpdated', handleDatabaseUpdate);
const eqBtn = document.getElementById('eqBtn');"" const eqBtn = document.getElementById('eqBtn');
const closeEqBtn = document.getElementById('closeEqBtn'); const closeEqBtn = document.getElementById('closeEqBtn');
const equalizerPanel = document.getElementById('equalizer-panel'); const equalizerPanel = document.getElementById('equalizer-panel');
@ -268,16 +283,16 @@ function handleMainViewClick(e) {
return; return;
} }
const card = e.target.closest('.item-card'); const itemCard = e.target.closest('.item-card, .provider-item-card');
if (!card) return; if (!itemCard) return;
state.lastClickedCardElement = card; state.lastClickedCardElement = itemCard;
const actionBtn = e.target.closest('.action-btn'); const actionBtn = e.target.closest('.action-btn');
if (actionBtn) { if (actionBtn) {
e.stopPropagation(); e.stopPropagation();
const { id, type } = card.dataset; const { id, type } = itemCard.dataset;
const title = card.querySelector('.item-title')?.textContent; const title = itemCard.querySelector('.item-title')?.textContent;
if (actionBtn.classList.contains('info-btn')) showItemDetails(Number(id), type); if (actionBtn.classList.contains('info-btn')) showItemDetails(Number(id), type);
else if (actionBtn.classList.contains('favorites-btn')) toggleFavorite(Number(id), type); else if (actionBtn.classList.contains('favorites-btn')) toggleFavorite(Number(id), type);
else if (actionBtn.classList.contains('play-btn')) addStreamToList(title, type, actionBtn); else if (actionBtn.classList.contains('play-btn')) addStreamToList(title, type, actionBtn);
@ -285,7 +300,7 @@ function handleMainViewClick(e) {
return; return;
} }
const { id, type } = card.dataset; const { id, type } = itemCard.dataset;
if (id && type) showItemDetails(Number(id), type); if (id && type) showItemDetails(Number(id), type);
} }

View File

@ -4,6 +4,7 @@ import { initDB, getFromDB } from './db.js';
import { MusicPlayer } from './musicPlayer.js'; import { MusicPlayer } from './musicPlayer.js';
import { ActivityViewer } from './activityViewer.js'; import { ActivityViewer } from './activityViewer.js';
import { setupEventListeners } from './eventListeners.js'; import { setupEventListeners } from './eventListeners.js';
import { fetchAllProviders, renderProviders, getRegions } from './providers.js';
import { loadInitialContent, initializeFavorites, initializeUserData, loadLocalContent, applyTheme, applyHeroVisibility } from './ui.js'; import { loadInitialContent, initializeFavorites, initializeUserData, loadLocalContent, applyTheme, applyHeroVisibility } from './ui.js';
import { showNotification, _ } from './utils.js'; import { showNotification, _ } from './utils.js';
@ -20,6 +21,19 @@ async function loadSettings() {
state.settings.apiKey = config.defaultApiKey; state.settings.apiKey = config.defaultApiKey;
} }
if (!state.settings.watchRegion) {
const tmdbLangMap = {
'es': 'es-ES',
'en': 'en-US',
'fr': 'fr-FR',
'de': 'de-DE',
'it': 'it-IT',
'pt': 'pt-BR'
};
const fullLangCode = tmdbLangMap[state.settings.language] || 'en-US';
state.settings.watchRegion = fullLangCode.split('-')[1] || 'US';
}
const jellyfinSettingsData = await getFromDB('jellyfin_settings'); const jellyfinSettingsData = await getFromDB('jellyfin_settings');
if (jellyfinSettingsData && jellyfinSettingsData.length > 0) { if (jellyfinSettingsData && jellyfinSettingsData.length > 0) {
state.jellyfinSettings = { ...state.jellyfinSettings, ...jellyfinSettingsData[0] }; state.jellyfinSettings = { ...state.jellyfinSettings, ...jellyfinSettingsData[0] };

178
js/providers.js Normal file
View File

@ -0,0 +1,178 @@
import { fetchTMDB } from './api.js';
import { state } from './state.js';
import { _, showNotification } from './utils.js';
import { renderGrid } from './ui.js';
let currentProviderId = null;
let currentPage = 1;
let totalPages = 1;
export async function getAvailableProviders(type, region) {
try {
const url = `watch/providers/${type}`;
const providers = await fetchTMDB(url, { watch_region: region });
return providers.results || [];
} catch (error) {
console.error(`Error fetching ${type} providers for region ${region}:`, error);
return [];
}
}
export async function fetchAllProviders(region) {
const [movieProviders, tvProviders] = await Promise.all([
getAvailableProviders('movie', region),
getAvailableProviders('tv', region)
]);
const allProvidersMap = new Map();
[...movieProviders, ...tvProviders].forEach(provider => {
allProvidersMap.set(provider.provider_id, provider);
});
return Array.from(allProvidersMap.values()).sort((a, b) => a.provider_name.localeCompare(b.provider_name));
}
export async function getRegions() {
try {
const regions = await fetchTMDB('watch/providers/regions');
return regions.results || [];
} catch (error) {
console.error('Error fetching regions:', error);
return [];
}
}
export function resetProvidersView() {
const providersSection = document.getElementById('providers-section');
if (!providersSection) return;
const grid = document.getElementById('providers-grid');
const itemsContainer = document.getElementById('provider-items-container');
const titleElement = document.querySelector('#providers-section .section-title');
const backButton = document.getElementById('back-to-providers');
if (grid) grid.style.display = 'grid';
if (itemsContainer) itemsContainer.style.display = 'none';
if (titleElement) titleElement.textContent = _('navProviders');
if (backButton) backButton.style.display = 'none';
}
export async function backToProviders() {
resetProvidersView();
const providers = await fetchAllProviders(state.settings.watchRegion);
renderProviders(providers);
}
export function renderProviders(providers) {
const grid = document.getElementById('providers-grid');
if (!grid) return;
grid.style.display = 'grid';
if (providers.length === 0) {
grid.innerHTML = `<div class="empty-state"><i class="fas fa-tv"></i><p>${_('noProvidersFound')}</p></div>`;
return;
}
const availableProviderIds = new Set(state.localSeries.flatMap(s => s.titulos).map(t => t.provider_id));
const providersHtml = providers.map(provider => {
const isAvailable = availableProviderIds.has(provider.provider_id);
return `
<div class="provider-card ${isAvailable ? 'available' : ''}" data-provider-id="${provider.provider_id}" data-provider-name="${provider.provider_name}">
<img src="https://image.tmdb.org/t/p/w200${provider.logo_path}" alt="${provider.provider_name}" class="provider-logo">
<div class="provider-tooltip">${provider.provider_name}</div>
</div>
`;
}).join('');
grid.innerHTML = providersHtml;
gsap.fromTo(".provider-card",
{ scale: 0.5, opacity: 0 },
{
duration: 0.5,
scale: 1,
opacity: 1,
delay: 0.2,
stagger: 0.08,
ease: "back.out(1.7)",
force3D: true
}
);
}
export async function getProviderItems(providerId, page = 1) {
try {
const watchRegion = state.settings.watchRegion || 'US';
const language = state.settings.language || 'en-US';
const params = {
with_watch_providers: providerId,
watch_region: watchRegion,
page: page,
language: language
};
const moviesResponse = await fetchTMDB('discover/movie', params);
const seriesResponse = await fetchTMDB('discover/tv', params);
const movies = moviesResponse.results.map(item => ({ ...item, media_type: 'movie' }));
const series = seriesResponse.results.map(item => ({ ...item, media_type: 'tv' }));
const items = [...movies, ...series].sort((a, b) => b.popularity - a.popularity);
const total_pages = Math.max(moviesResponse.total_pages, seriesResponse.total_pages);
return { success: true, items, total_pages };
} catch (error) {
showNotification(`${_('couldNotLoadContent')}: ${error.message}`, 'error');
return { success: false, items: [], total_pages: 0 };
}
}
export async function loadProviderContent(providerId, providerName, page = 1) {
currentProviderId = providerId;
currentPage = page;
const itemsContainer = document.getElementById('provider-items-container');
const providersGrid = document.getElementById('providers-grid');
const itemsGrid = document.getElementById('provider-items');
const paginationControls = document.getElementById('pagination-controls');
const pageNumberSpan = document.getElementById('page-number');
const prevButton = document.getElementById('prev-page');
const nextButton = document.getElementById('next-page');
const sectionTitle = document.querySelector('#providers-section .section-title');
const backButton = document.getElementById('back-to-providers');
providersGrid.style.display = 'none';
itemsContainer.style.display = 'block';
backButton.style.display = 'block';
if (sectionTitle) sectionTitle.textContent = providerName;
itemsGrid.innerHTML = `<div class="col-12 text-center mt-5"><div class="spinner" style="position: static; margin: auto; display: block;"></div></div>`;
paginationControls.style.display = 'none';
const { success, items, total_pages } = await getProviderItems(providerId, page);
if (success) {
totalPages = total_pages;
renderGrid(items, false, 'provider-items');
if (totalPages > 1) {
paginationControls.style.display = 'flex';
pageNumberSpan.textContent = `${_('page')} ${currentPage} / ${totalPages}`;
prevButton.disabled = currentPage === 1;
nextButton.disabled = currentPage >= totalPages;
}
} else {
itemsGrid.innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p>${_('couldNotLoadContent')}</p></div>`;
}
}
export function changeProviderPage(direction) {
const newPage = currentPage + direction;
if (newPage > 0 && newPage <= totalPages && currentProviderId) {
const providerName = document.querySelector('#providers-section .section-title').textContent;
loadProviderContent(currentProviderId, providerName, newPage);
}
}

View File

@ -56,4 +56,6 @@ export const state = {
currentPhotoToken: null, currentPhotoToken: null,
currentPhotoItems: [], currentPhotoItems: [],
currentPhotoLightboxIndex: 0, currentPhotoLightboxIndex: 0,
providerContentPage: 1,
providerContentTotalPages: 1,
}; };

220
js/ui.js
View File

@ -2,15 +2,34 @@ import { state } from './state.js';
import { fetchTMDB, fetchAllAvailableStreams } from './api.js'; import { fetchTMDB, fetchAllAvailableStreams } from './api.js';
import { showNotification, getRelativeTime, fetchWithTimeout, _ } from './utils.js'; import { showNotification, getRelativeTime, fetchWithTimeout, _ } from './utils.js';
import { getFromDB, addItemsToStore } from './db.js'; import { getFromDB, addItemsToStore } from './db.js';
import { getAvailableProviders, renderProviders, getRegions, fetchAllProviders, resetProvidersView } from './providers.js';
let charts = {}; let charts = {};
export async function loadInitialContent() { export async function loadInitialContent() {
await Promise.all([loadGenres(), loadYears()]); await Promise.all([loadGenres(), loadYears(), loadRegions()]);
resetView(); resetView();
setupScrollEffects(); setupScrollEffects();
} }
async function loadRegions() {
const select = document.getElementById('region-filter');
select.innerHTML = `<option value="">${_('loadingRegions')}</option>`;
try {
const data = await getRegions();
select.innerHTML = `<option value="">${_('allRegions')}</option>`;
data.forEach(region => {
const option = document.createElement('option');
option.value = region.iso_3166_1;
option.textContent = region.english_name;
select.appendChild(option);
});
select.value = state.settings.watchRegion || "";
} catch (error) {
select.innerHTML = `<option value="">${_('errorLoadingRegions')}</option>`;
}
}
export function initializeUserData() { export function initializeUserData() {
try { try {
const savedHistory = localStorage.getItem('cineplex_userHistory'); const savedHistory = localStorage.getItem('cineplex_userHistory');
@ -71,6 +90,7 @@ export function resetView() {
document.getElementById('history-section').style.display = 'none'; document.getElementById('history-section').style.display = 'none';
document.getElementById('recommendations-section').style.display = 'none'; document.getElementById('recommendations-section').style.display = 'none';
document.getElementById('photos-section').style.display = 'none'; document.getElementById('photos-section').style.display = 'none';
document.getElementById('providers-section').style.display = 'none';
// Show hero if enabled // Show hero if enabled
if (heroSection) { if (heroSection) {
@ -109,9 +129,12 @@ export function resetView() {
} }
export function switchView(viewType) { export function switchView(viewType) {
console.log(`switchView called with viewType: ${viewType}`);
if (state.isLoading) return; if (state.isLoading) return;
resetProvidersView();
resetProvidersView();
const heroSection = document.getElementById('hero-section'); const heroSection = document.getElementById('hero-section');
const mainContent = document.querySelector('.main-content'); const mainContent = document.querySelector('.main-content');
@ -152,22 +175,38 @@ export function switchView(viewType) {
document.getElementById('search-input').value = ''; document.getElementById('search-input').value = '';
state.lastScrollPosition = 0; state.lastScrollPosition = 0;
const allSections = ['content-section', 'stats-section', 'history-section', 'recommendations-section', 'photos-section']; const allSections = ['content-section', 'stats-section', 'history-section', 'recommendations-section', 'photos-section', 'providers-section'];
allSections.forEach(id => { allSections.forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.style.display = 'none'; if (el) el.style.display = 'none';
}); });
// Explicitly reset the Providers section to its initial state before view switch
const providersSection = document.getElementById('providers-section');
if (providersSection) {
const detailsContainer = document.getElementById('provider-details-container');
if (detailsContainer) detailsContainer.style.display = 'none';
const gridContainer = document.getElementById('provider-grid-container');
if (gridContainer) gridContainer.style.display = 'grid';
const sectionTitle = providersSection.querySelector('.section-title');
if (sectionTitle) sectionTitle.textContent = _('navProviders');
const backArrow = providersSection.querySelector('.back-arrow');
if (backArrow) backArrow.style.display = 'none';
}
const backToProvidersBtn = document.getElementById('back-to-providers-btn');
if (backToProvidersBtn) backToProvidersBtn.style.display = 'none';
const filters = document.querySelector('.filters'); const filters = document.querySelector('.filters');
if (filters) filters.style.display = 'none'; if (filters) filters.style.display = 'none';
switch(viewType) { switch(viewType) {
case 'movies': case 'movies':
console.log('switchView: case movies');
case 'series': case 'series':
console.log('switchView: case series');
case 'search': case 'search':
console.log('switchView: case search');
document.getElementById('content-section').style.display = 'block'; document.getElementById('content-section').style.display = 'block';
filters.style.display = 'flex'; filters.style.display = 'flex';
if (viewType !== 'search') { if (viewType !== 'search') {
@ -178,27 +217,24 @@ export function switchView(viewType) {
} }
break; break;
case 'favorites': case 'favorites':
console.log('switchView: case favorites');
document.getElementById('content-section').style.display = 'block'; document.getElementById('content-section').style.display = 'block';
break; break;
case 'history': case 'history':
console.log('switchView: case history');
document.getElementById('history-section').style.display = 'block'; document.getElementById('history-section').style.display = 'block';
break; break;
case 'recommendations': case 'recommendations':
console.log('switchView: case recommendations');
document.getElementById('recommendations-section').style.display = 'block'; document.getElementById('recommendations-section').style.display = 'block';
break; break;
case 'stats': case 'stats':
console.log('switchView: case stats');
document.getElementById('stats-section').style.display = 'block'; document.getElementById('stats-section').style.display = 'block';
console.log('switchView: Showing stats-section');
document.getElementById('stats-filters').style.display = 'flex'; document.getElementById('stats-filters').style.display = 'flex';
break; break;
case 'photos': case 'photos':
document.getElementById('photos-section').style.display = 'block'; document.getElementById('photos-section').style.display = 'block';
break; break;
case 'providers':
document.getElementById('providers-section').style.display = 'block';
break;
} }
updateActiveNav(viewType); updateActiveNav(viewType);
@ -227,6 +263,9 @@ export function switchView(viewType) {
case 'photos': case 'photos':
initPhotosView(); initPhotosView();
break; break;
case 'providers':
loadProviders();
break;
} }
if (document.getElementById('item-details-view').classList.contains('active')) { if (document.getElementById('item-details-view').classList.contains('active')) {
@ -267,6 +306,7 @@ export function updateSectionTitle() {
case 'history': title = _('historyTitle'); break; case 'history': title = _('historyTitle'); break;
case 'recommendations': title = _('recommendationsTitle'); break; case 'recommendations': title = _('recommendationsTitle'); break;
case 'photos': title = _('navPhotos'); break; case 'photos': title = _('navPhotos'); break;
case 'providers': title = _('navProviders'); break;
case 'search': case 'search':
title = state.currentParams.query.startsWith('actor:') title = state.currentParams.query.startsWith('actor:')
? _('contentFrom', state.currentParams.query.split(':')[1]) ? _('contentFrom', state.currentParams.query.split(':')[1])
@ -276,7 +316,7 @@ export function updateSectionTitle() {
} }
let targetTitleElement; let targetTitleElement;
if (['stats', 'history', 'recommendations'].includes(state.currentView)) { if (['stats', 'history', 'recommendations', 'providers'].includes(state.currentView)) {
targetTitleElement = document.querySelector(`#${state.currentView}-section .section-title`); targetTitleElement = document.querySelector(`#${state.currentView}-section .section-title`);
} else { } else {
targetTitleElement = mainTitleElement; targetTitleElement = mainTitleElement;
@ -284,6 +324,10 @@ export function updateSectionTitle() {
if(targetTitleElement && state.currentView !== 'photos') { if(targetTitleElement && state.currentView !== 'photos') {
targetTitleElement.textContent = title; targetTitleElement.textContent = title;
const backArrow = targetTitleElement.parentElement.querySelector('.back-arrow');
if (backArrow) {
backArrow.style.display = 'none';
}
} }
} }
@ -349,7 +393,6 @@ function loadYears() {
} }
export async function loadContent(append = false) { export async function loadContent(append = false) {
console.log(`loadContent called with append: ${append}, currentView: ${state.currentView}, contentType: ${state.currentParams.contentType}`);
if (state.currentContentFetchController) state.currentContentFetchController.abort(); if (state.currentContentFetchController) state.currentContentFetchController.abort();
state.currentContentFetchController = new AbortController(); state.currentContentFetchController = new AbortController();
const signal = state.currentContentFetchController.signal; const signal = state.currentContentFetchController.signal;
@ -381,13 +424,11 @@ export async function loadContent(append = false) {
} }
const data = await fetchTMDB(endpoint, signal); const data = await fetchTMDB(endpoint, signal);
console.log('loadContent: Data fetched successfully', data);
renderGrid(data.results, append); renderGrid(data.results, append);
loadMoreButton.style.display = (data.page < data.total_pages) ? 'block' : 'none'; loadMoreButton.style.display = (data.page < data.total_pages) ? 'block' : 'none';
if (!append) setupScrollEffects(); if (!append) setupScrollEffects();
} catch (error) { } catch (error) {
console.error('loadContent: Error fetching content', error);
if (error.name !== 'AbortError') { if (error.name !== 'AbortError') {
if (!append) grid.innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p>${_('couldNotLoadContent')}</p></div>`; if (!append) grid.innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p>${_('couldNotLoadContent')}</p></div>`;
} }
@ -400,35 +441,38 @@ export async function loadContent(append = false) {
} }
} }
function isContentAvailableLocally(title, type) { function isContentAvailableLocally(title, type, year) {
if (!title || !type) return false; if (!title || !type) return false;
const normalizedTitle = title.toLowerCase().trim();
const plexSource = type === 'movie' ? state.localMovies : state.localSeries; const normalize = (str) => str.toLowerCase().trim().replace(/\s+/g, ' ');
if (Array.isArray(plexSource)) { const normalizedTitle = normalize(title);
const foundInPlex = plexSource.some(server =>
const checkSource = (source, itemType) => {
if (!Array.isArray(source)) return false;
return source.some(server =>
server && Array.isArray(server.titulos) && server && Array.isArray(server.titulos) &&
server.titulos.some(t => t && typeof t.title === 'string' && t.title.toLowerCase().trim() === normalizedTitle) server.titulos.some(t => {
if (!t || typeof t.title !== 'string') return false;
const localTitle = normalize(t.title);
if (localTitle !== normalizedTitle) return false;
if (year && t.year && Math.abs(parseInt(t.year) - parseInt(year)) > 1) return false;
if (itemType && t.type && t.type.toLowerCase() !== itemType.toLowerCase()) return false;
return true;
})
); );
if (foundInPlex) return true; };
}
const jellyfinType = type === 'movie' ? 'Movie' : 'Series'; if (checkSource(state.localMovies, 'movie')) return true;
const jellyfinSource = type === 'movie' ? state.jellyfinMovies : state.jellyfinSeries; if (checkSource(state.localSeries, 'series')) return true;
if (Array.isArray(jellyfinSource)) { if (checkSource(state.jellyfinMovies, 'movie')) return true;
const foundInJellyfin = jellyfinSource.some(library => if (checkSource(state.jellyfinSeries, 'series')) return true;
library && Array.isArray(library.titulos) &&
library.titulos.some(t => t && typeof t.title === 'string' && t.title.toLowerCase().trim() === normalizedTitle && t.type === jellyfinType)
);
if (foundInJellyfin) return true;
}
return false; return false;
} }
function renderGrid(items, append = false) { export function renderGrid(items, append = false, gridId = 'content-grid') {
const grid = document.getElementById('content-grid'); const grid = document.getElementById(gridId);
if (!append) grid.innerHTML = ''; if (!append) grid.innerHTML = '';
if (!items || items.length === 0) { if (!items || items.length === 0) {
@ -452,7 +496,7 @@ function renderGrid(items, append = false) {
const releaseDate = isMovie ? item.release_date : item.first_air_date; const releaseDate = isMovie ? item.release_date : item.first_air_date;
const year = releaseDate ? releaseDate.slice(0, 4) : 'N/A'; const year = releaseDate ? releaseDate.slice(0, 4) : 'N/A';
const posterPath = item.poster_path ? `https://image.tmdb.org/t/p/w500${item.poster_path}` : 'img/no-poster.png'; const posterPath = item.poster_path ? `https://image.tmdb.org/t/p/w500${item.poster_path}` : 'img/no-poster.png';
const isAvailable = isContentAvailableLocally(title, itemType); const isAvailable = isContentAvailableLocally(title, itemType, year);
const isFavorite = state.favorites.some(fav => fav.id === item.id && fav.type === itemType); const isFavorite = state.favorites.some(fav => fav.id === item.id && fav.type === itemType);
const voteAvg = item.vote_average ? item.vote_average.toFixed(1) : 'N/A'; const voteAvg = item.vote_average ? item.vote_average.toFixed(1) : 'N/A';
const ratingClass = voteAvg >= 7.5 ? 'rating-good' : (voteAvg >= 5.0 ? 'rating-ok' : 'rating-bad'); const ratingClass = voteAvg >= 7.5 ? 'rating-good' : (voteAvg >= 5.0 ? 'rating-ok' : 'rating-bad');
@ -818,12 +862,10 @@ function updateFavoriteButtonVisuals(itemId, itemType, isFavorite) {
} }
export async function loadFavorites() { export async function loadFavorites() {
console.log('loadFavorites called');
const grid = document.getElementById('content-grid'); const grid = document.getElementById('content-grid');
grid.innerHTML = '<div class="col-12 text-center mt-5"><div class="spinner" style="position: static; margin: auto; display: block;"></div></div>'; grid.innerHTML = '<div class="col-12 text-center mt-5"><div class="spinner" style="position: static; margin: auto; display: block;"></div></div>';
if (state.favorites.length === 0) { if (state.favorites.length === 0) {
console.log('loadFavorites: No favorites found.');
grid.innerHTML = `<div class="empty-state"><i class="far fa-heart fa-3x mb-3"></i><p class="lead">${_('noFavorites')}</p></div>`; grid.innerHTML = `<div class="empty-state"><i class="far fa-heart fa-3x mb-3"></i><p class="lead">${_('noFavorites')}</p></div>`;
return; return;
} }
@ -831,7 +873,6 @@ export async function loadFavorites() {
try { try {
const favoritePromises = state.favorites.map(fav => fetchTMDB(`${fav.type}/${fav.id}`).catch(()=>null)); const favoritePromises = state.favorites.map(fav => fetchTMDB(`${fav.type}/${fav.id}`).catch(()=>null));
const favoriteItems = (await Promise.all(favoritePromises)).filter(item => item !== null); const favoriteItems = (await Promise.all(favoritePromises)).filter(item => item !== null);
console.log('loadFavorites: Data received for rendering', favoriteItems);
renderGrid(favoriteItems, false); renderGrid(favoriteItems, false);
} catch (error) { } catch (error) {
grid.innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p>${_('errorLoadingFavorites')}</p></div>`; grid.innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p>${_('errorLoadingFavorites')}</p></div>`;
@ -1541,7 +1582,8 @@ export async function saveSettings() {
phpSecretKey: document.getElementById('phpSecretKey').value.trim(), phpSecretKey: document.getElementById('phpSecretKey').value.trim(),
phpSavePath: document.getElementById('phpSavePath').value.trim(), phpSavePath: document.getElementById('phpSavePath').value.trim(),
phpFilename: document.getElementById('phpFilename').value.trim(), phpFilename: document.getElementById('phpFilename').value.trim(),
phpFileAction: document.getElementById('phpFileActionAppend').checked ? 'append' : 'overwrite' phpFileAction: document.getElementById('phpFileActionAppend').checked ? 'append' : 'overwrite',
watchRegion: document.getElementById('region-filter').value
}; };
state.settings = { ...state.settings, ...newSettings }; state.settings = { ...state.settings, ...newSettings };
@ -1616,94 +1658,11 @@ export const phpScriptGenerator = (() => {
const filename = dom.filename.value.trim() || 'CinePlex_Playlist.m3u'; const filename = dom.filename.value.trim() || 'CinePlex_Playlist.m3u';
const appendToFile = dom.fileActionAppendRadio.checked; const appendToFile = dom.fileActionAppendRadio.checked;
let script = `<?php let script = `<?php\nheader('Content-Type: application/json');\nheader('Access-Control-Allow-Origin: *');\nheader('Access-Control-Allow-Methods: POST, OPTIONS');\nheader('Access-Control-Allow-Headers: Content-Type, Origin, X-Secret-Key');\n\nif ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {\n http_response_code(200);\n exit(0);\n}\n\ndefine('SAVE_DIRECTORY', '${savePath.replace(/'/g, "\\'")}');\ndefine('FILENAME', '${filename.replace(/'/g, "\\'")}');\ndefine('FILE_ACTION_APPEND', ${appendToFile ? 'true' : 'false'});\n${useSecretKey ? `define('SECRET_KEY', '${secretKey.replace(/'/g, "\\'")}');` : ''}\n\nfunction sendResponse($success, $message, $filename = '', $http_code = 200) {\n if (!$success && $http_code === 200) {\n $http_code = 400;\n }\n http_response_code($http_code);\n echo json_encode(['success' => $success, 'message' => $message, 'filename' => $filename]);\n exit;\n}\n`;
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Origin, X-Secret-Key');
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit(0);
}
define('SAVE_DIRECTORY', '${savePath.replace(/'/g, "\\'")}');
define('FILENAME', '${filename.replace(/'/g, "\\'")}');
define('FILE_ACTION_APPEND', ${appendToFile ? 'true' : 'false'});
${useSecretKey ? `define('SECRET_KEY', '${secretKey.replace(/'/g, "\\'")}');` : ''}
function sendResponse($success, $message, $filename = '', $http_code = 200) {
if (!$success && $http_code === 200) {
$http_code = 400;
}
http_response_code($http_code);
echo json_encode(['success' => $success, 'message' => $message, 'filename' => $filename]);
exit;
}
`;
if (useSecretKey) { if (useSecretKey) {
script += ` script += `\n$auth_key = isset($_SERVER['HTTP_X_SECRET_KEY']) ? $_SERVER['HTTP_X_SECRET_KEY'] : '';\nif (!defined('SECRET_KEY') || SECRET_KEY === '' || $auth_key !== SECRET_KEY) {\n sendResponse(false, 'Acceso no autorizado. Clave secreta inválida o no proporcionada.', '', 403);\n}\n`;
$auth_key = isset($_SERVER['HTTP_X_SECRET_KEY']) ? $_SERVER['HTTP_X_SECRET_KEY'] : '';
if (!defined('SECRET_KEY') || SECRET_KEY === '' || $auth_key !== SECRET_KEY) {
sendResponse(false, 'Acceso no autorizado. Clave secreta inválida o no proporcionada.', '', 403);
}
`;
} }
script += ` script += `\n$json_data = file_get_contents('php://input');\n$data = json_decode($json_data, true);\n\nif (json_last_error() !== JSON_ERROR_NONE) {\n sendResponse(false, 'Error: Datos JSON inválidos.');\n}\n\nif (!isset($data['streams']) || !is_array($data['streams']) || empty($data['streams'])) {\n sendResponse(false, 'Error: El JSON debe contener un array "streams" no vacío.');\n}\n\n$save_dir = SAVE_DIRECTORY !== '' ? rtrim(SAVE_DIRECTORY, '/\\') : __DIR__;\n\nif (!is_dir($save_dir) || !is_writable($save_dir)) {\n sendResponse(false, 'Error del servidor: El directorio de destino no existe o no tiene permisos de escritura.', '', 500);\n}\n\n$safe_filename = preg_replace('/[^\\w\\s._-]/', '', basename(FILENAME));\n$safe_filename = preg_replace('/\\s+/', '_', $safe_filename);\n$target_path = $save_dir . DIRECTORY_SEPARATOR . $safe_filename;\n\n$content_to_write = "";\n\nif (FILE_ACTION_APPEND) {\n $file_exists = file_exists($target_path);\n if (!$file_exists) {\n $content_to_write .= "#EXTM3U\\n";\n }\n foreach ($data['streams'] as $stream) {\n if (isset($stream['extinf'], $stream['url'])) {\n $content_to_write .= trim($stream['extinf']) . "\\n";\n $content_to_write .= trim($stream['url']) . "\\n";\n }\n }\n if (file_put_contents($target_path, $content_to_write, FILE_APPEND | LOCK_EX) !== false) {\n sendResponse(true, 'Streams añadidos correctamente al archivo.', $safe_filename, 200);\n } else {\n sendResponse(false, 'Error del servidor: No se pudo añadir contenido al archivo.', '', 500);\n }\n} else { // Overwrite mode\n $content_to_write = "#EXTM3U\\n";\n foreach ($data['streams'] as $stream) {\n if (isset($stream['extinf'], $stream['url'])) {\n $content_to_write .= trim($stream['extinf']) . "\\n";\n $content_to_write .= trim($stream['url']) . "\\n";\n }\n }\n if (file_put_contents($target_path, $content_to_write, LOCK_EX) !== false) {\n sendResponse(true, 'Archivo de streams sobrescrito correctamente.', $safe_filename, 201);\n } else {\n sendResponse(false, 'Error del servidor: No se pudo escribir el archivo.', '', 500);\n }\n}\n?>`;
$json_data = file_get_contents('php://input');
$data = json_decode($json_data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
sendResponse(false, 'Error: Datos JSON inválidos.');
}
if (!isset($data['streams']) || !is_array($data['streams']) || empty($data['streams'])) {
sendResponse(false, 'Error: El JSON debe contener un array "streams" no vacío.');
}
$save_dir = SAVE_DIRECTORY !== '' ? rtrim(SAVE_DIRECTORY, '/\\\\') : __DIR__;
if (!is_dir($save_dir) || !is_writable($save_dir)) {
sendResponse(false, 'Error del servidor: El directorio de destino no existe o no tiene permisos de escritura.', '', 500);
}
$safe_filename = preg_replace('/[^\\w\\s._-]/', '', basename(FILENAME));
$safe_filename = preg_replace('/\\s+/', '_', $safe_filename);
$target_path = $save_dir . DIRECTORY_SEPARATOR . $safe_filename;
$content_to_write = "";
if (FILE_ACTION_APPEND) {
$file_exists = file_exists($target_path);
if (!$file_exists) {
$content_to_write .= "#EXTM3U\\n";
}
foreach ($data['streams'] as $stream) {
if (isset($stream['extinf'], $stream['url'])) {
$content_to_write .= trim($stream['extinf']) . "\\n";
$content_to_write .= trim($stream['url']) . "\\n";
}
}
if (file_put_contents($target_path, $content_to_write, FILE_APPEND | LOCK_EX) !== false) {
sendResponse(true, 'Streams añadidos correctamente al archivo.', $safe_filename, 200);
} else {
sendResponse(false, 'Error del servidor: No se pudo añadir contenido al archivo.', '', 500);
}
} else { // Overwrite mode
$content_to_write = "#EXTM3U\\n";
foreach ($data['streams'] as $stream) {
if (isset($stream['extinf'], $stream['url'])) {
$content_to_write .= trim($stream['extinf']) . "\\n";
$content_to_write .= trim($stream['url']) . "\\n";
}
}
if (file_put_contents($target_path, $content_to_write, LOCK_EX) !== false) {
sendResponse(true, 'Archivo de streams sobrescrito correctamente.', $safe_filename, 201);
} else {
sendResponse(false, 'Error del servidor: No se pudo escribir el archivo.', '', 500);
}
}
?>`;
dom.generatedCode.value = script; dom.generatedCode.value = script;
showNotification(_("scriptGenerated"), "success"); showNotification(_("scriptGenerated"), "success");
} }
@ -1764,7 +1723,7 @@ export function handlePhotoTokenChange() {
} }
state.currentPhotoToken = selectedToken; state.currentPhotoToken = selectedToken;
state.currentPhotoServer = state.localPhotos.find(s => s.tokenPrincipal === state.currentPhotoToken); state.currentPhotoServer = state.localPhotos.find(s => s.tokenPrincipal === selectedToken);
state.photoStack = []; state.photoStack = [];
renderPhotoBreadcrumb(); renderPhotoBreadcrumb();
@ -1992,3 +1951,10 @@ export function showPrevPhoto() {
state.currentPhotoLightboxIndex = (state.currentPhotoLightboxIndex - 1 + state.currentPhotoItems.length) % state.currentPhotoItems.length; state.currentPhotoLightboxIndex = (state.currentPhotoLightboxIndex - 1 + state.currentPhotoItems.length) % state.currentPhotoItems.length;
updatePhotoLightbox(); updatePhotoLightbox();
} }
export async function loadProviders() {
const providers = await fetchAllProviders(state.settings.watchRegion);
renderProviders(providers);
}

View File

@ -13,6 +13,7 @@
<link rel="stylesheet" href="css/main.css"> <link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/equalizer.css"> <link rel="stylesheet" href="css/equalizer.css">
<link rel="stylesheet" href="css/photos.css"> <link rel="stylesheet" href="css/photos.css">
<link rel="stylesheet" href="css/providers.css">
</head> </head>
<body class="unlocalized"> <body class="unlocalized">
@ -46,6 +47,7 @@
<ul class="sidebar-menu"> <ul class="sidebar-menu">
<li><a class="nav-link active" href="#" id="nav-movies"><i class="fas fa-film"></i><span>__MSG_navMovies__</span></a></li> <li><a class="nav-link active" href="#" id="nav-movies"><i class="fas fa-film"></i><span>__MSG_navMovies__</span></a></li>
<li><a class="nav-link" href="#" id="nav-series"><i class="fas fa-tv"></i><span>__MSG_navSeries__</span></a></li> <li><a class="nav-link" href="#" id="nav-series"><i class="fas fa-tv"></i><span>__MSG_navSeries__</span></a></li>
<li><a class="nav-link" href="#" id="nav-providers"><i class="fas fa-broadcast-tower"></i><span>__MSG_navProviders__</span></a></li>
<li><a class="nav-link" href="#" id="nav-photos"><i class="fas fa-images"></i><span>__MSG_navPhotos__</span></a></li> <li><a class="nav-link" href="#" id="nav-photos"><i class="fas fa-images"></i><span>__MSG_navPhotos__</span></a></li>
<li><a class="nav-link" href="#" id="nav-stats"><i class="fas fa-chart-pie"></i><span>__MSG_navStats__</span></a></li> <li><a class="nav-link" href="#" id="nav-stats"><i class="fas fa-chart-pie"></i><span>__MSG_navStats__</span></a></li>
<li><a class="nav-link" href="#" id="nav-favorites"><i class="fas fa-heart"></i><span>__MSG_navFavorites__</span></a></li> <li><a class="nav-link" href="#" id="nav-favorites"><i class="fas fa-heart"></i><span>__MSG_navFavorites__</span></a></li>
@ -104,6 +106,22 @@
<div class="text-center mt-5"><button id="load-more" class="btn btn-primary" style="display: none;">__MSG_loadMore__</button></div> <div class="text-center mt-5"><button id="load-more" class="btn btn-primary" style="display: none;">__MSG_loadMore__</button></div>
</section> </section>
<section id="providers-section" style="display: none;">
<div class="section-header">
<h2 class="section-title">__MSG_navProviders__</h2>
</div>
<div id="providers-grid" class="providers-grid"></div>
<div id="provider-items-container" style="display: none;">
<button id="back-to-providers" class="btn btn-secondary mb-3"><i class="fas fa-arrow-left me-2"></i>__MSG_backToProviders__</button>
<div id="provider-items" class="content-grid"></div>
<div id="pagination-controls" class="text-center mt-4" style="display: none;">
<button id="prev-page" class="btn btn-primary">&larr; __MSG_previous__</button>
<span id="page-number" class="mx-3 align-middle"></span>
<button id="next-page" class="btn btn-primary">__MSG_next__ &rarr;</button>
</div>
</div>
</section>
<section id="photos-section" style="display: none;"> <section id="photos-section" style="display: none;">
<div class="photos-header"> <div class="photos-header">
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
@ -231,6 +249,7 @@
<div class="footer-links"> <div class="footer-links">
<a href="#" class="footer-link" id="footer-movies">__MSG_navMovies__</a> <a href="#" class="footer-link" id="footer-movies">__MSG_navMovies__</a>
<a href="#" class="footer-link" id="footer-series">__MSG_navSeries__</a> <a href="#" class="footer-link" id="footer-series">__MSG_navSeries__</a>
<a href="#" class="footer-link" id="footer-providers">__MSG_navProviders__</a>
<a href="#" class="footer-link" id="footer-stats">__MSG_navStats__</a> <a href="#" class="footer-link" id="footer-stats">__MSG_navStats__</a>
<a href="#" class="footer-link" id="footer-favorites">__MSG_navFavorites__</a> <a href="#" class="footer-link" id="footer-favorites">__MSG_navFavorites__</a>
</div> </div>
@ -349,6 +368,12 @@
<option value="it">__MSG_lang_it__</option> <option value="it">__MSG_lang_it__</option>
<option value="pt">__MSG_lang_pt__</option> <option value="pt">__MSG_lang_pt__</option>
</select> </select>
</div>
<div class="mb-3">
<label for="region-filter" class="form-label">__MSG_settingsRegionLabel__</label>
<select class="form-control filter-select" id="region-filter">
<option value="">__MSG_allRegions__</option>
</select>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="phpScriptUrl" class="form-label">__MSG_settingsPhpUrlLabel__</label> <label for="phpScriptUrl" class="form-label">__MSG_settingsPhpUrlLabel__</label>