├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── functions ├── Add-TagToMedia.ps1 ├── Contains-SyncKeyword.ps1 ├── Enqueue-Payload.ps1 ├── Extract-LanguageCodeFromPath.ps1 ├── Extract-Offset.ps1 ├── Generate-UserAudioPreferences.ps1 ├── Generate-UserSubtitlePreferences.ps1 ├── Get-BazarrEpisodeSubtitlePath.ps1 ├── Get-BazarrLanguageCode.ps1 ├── Get-BazarrMovieSubtitlePath.ps1 ├── Get-GPTTranslationChunked.ps1 ├── Get-PlexLibraryId.ps1 ├── Get-PlexUserTokens.ps1 ├── Get-RadarrMovieDetails.ps1 ├── Get-SonarrEpisodeDetails.ps1 ├── Get-SonarrEpisodesBySeason.ps1 ├── Get-SonarrSeriesId.ps1 ├── Get-SubtitlePath.ps1 ├── Get-SubtitleText.ps1 ├── Handle-MediaAvailable.ps1 ├── Handle-OtherIssue.ps1 ├── Handle-SonarrEpisodeFileAdded.ps1 ├── Handle-SubtitlesIssue.ps1 ├── Handle-Webhook.ps1 ├── Log-Message.ps1 ├── Map-LanguageCode.ps1 ├── Post-OverseerrComment.ps1 ├── Process-Queue.ps1 ├── Resolve-OverseerrIssue.ps1 ├── Set-AudioTrack.ps1 ├── Set-SubtitleText.ps1 ├── Set-SubtitleTrack.ps1 ├── ShiftOffset.ps1 ├── Start-OverseerrRequestMonitor.ps1 ├── Translate-Message.ps1 └── Trigger-Kometa.ps1 ├── overr-syncerr-main.ps1 └── previews ├── movies.gif ├── script_log.png └── series.gif /.gitignore: -------------------------------------------------------------------------------- 1 | # Secrets and sensitive files 2 | .env 3 | docker-compose.yml 4 | config/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/powershell:latest 2 | 3 | RUN apt update && apt install -y docker.io 4 | 5 | WORKDIR /app 6 | 7 | COPY . . 8 | 9 | ENTRYPOINT ["pwsh", "/app/overr-syncerr-main.ps1"] 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 gssariev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overr-Syncerr 2 | 3 | This project was created to improve my Plex experience—and that of my users—by giving them just enough control to solve one of the biggest challenges: subtitles. Over time, it has expanded to include features like automatic media labeling, better translations, and, most recently, personalized audio preferences. Now, users can have their preferred language, codec, and channel count automatically selected, ensuring the best audio experience while reducing unnecessary transcoding. 4 | While this project is designed around my setup, you're welcome to adapt it to fit your own needs! 5 | 6 | Overr-Syncerr is a script designed to automate the management of subtitle synchronization issues across your media library. By leveraging **[Overseerr](https://overseerr.dev)** and **[Jellyseerr](https://github.com/Fallenbagel/jellyseerr)**'s built-in webhook and issue reporting functionality, this script allows users to specify the subtitles they need synchronized. It seamlessly integrates with your existing services such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**, and **[Bazarr](https://www.bazarr.media)**, making the entire process of subtitle synchronization more automated. 7 | 8 |

9 | Overr-Syncerr_logo 10 |

11 | 12 |

13 | GitHub Release 14 | GitHub Repo stars 15 | Docker Pulls 16 | GitHub commit activity 17 | GitHub Issues or Pull Requests 18 | GitHub Issues or Pull Requests 19 | Wiki 20 |

21 | 22 | ## Getting Started 23 | 24 | Refer to the official Overr-Syncerr docs at - https://wiki.overrsyncerr.info 25 | 26 | ## IMPORTANT ## 27 | **Enable Overseer Request Monitor **AFTER** you've run the script to generate subtitle and audio preferences jsons to ensure that the correct settings get applied.** 28 | 29 | ## Current Features 30 | 31 | - **Full Sonarr and Radarr Integration**: Easily fetch series and movie details from Sonarr and Radarr. 32 | - **Bazarr Integration**: Synchronize subtitles using Bazarr, including support for 4K instances and HI subtitles. 33 | - **Language Mapping**: Map keywords to language names based on webhook messages. 34 | - **Subtitles**: Send 'sync', 'translate' and manual adjustment requests to Bazarr (using the 1st audio track + GSS). 35 | - **Auto-reply & resolve issue**: Automatically reply to the reported subtitle issue in Overseerr/Jellyseerr upon subtitles synchronization and mark it as resolved. 36 | - **Sync all episodes in season**: Submit all subtitles in a specific language to be synced by selecting 'All Episodes' when submitting the subtitle issue. 37 | - **User Audio Preference:** set preffered audio track based on language, codec and channel per user automatically once media becomes available 38 | - **User Subtitle Preference:** set preffered subtitle track based on language, codec, forced or hearing impaired properties per user automatically once media becomes available 39 | - **Auto-labelling**: Option to label available requested media with the username of the requester in Plex (inspired by [Plex Requester Collection](https://github.com/manybothans/plex-requester-collections)) 40 | - **Translate Subs Using GPT**: Option to use OpenAI GPT instead of Google Translate for subtitle translation **OpenAI API Key Required** 41 | 42 | ## Known issues (WIP) 43 | 44 | - If you've encountered and issue or have a suggestions, you're welcome to post about it :) 45 | 46 | ## To-Do 47 | - Integrate Mediux for automatic poster and title card application based on favourite creators (**NO** **ETA**) 48 | ## Contributors 49 | 50 | Big thank you to the people helping furher develop this project! 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 |

kirsch33

💻

nwithan8

💻
68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | overr-syncerr: 3 | image: gsariev/overr-syncerr:latest 4 | container_name: overr-syncerr 5 | ports: 6 | - "8089:8089" 7 | environment: 8 | #BAZARR CONFIG 9 | BAZARR_API_KEY: "YOUR_BAZARR_API_KEY" 10 | BAZARR_URL: "http://BAZARR_URL:PORT/api" 11 | 12 | BAZARR_4K_API_KEY: "YOUR_BAZARR_4K_API_KEY" 13 | BAZARR_4K_URL: "http://BAZARR_4K_URL:PORT/api" 14 | 15 | #RADARR CONFIG 16 | RADARR_API_KEY: "YOUR_RADARR_API_KEY" 17 | RADARR_URL: "http://RADARR_URL:PORT/api/v3" 18 | 19 | RADARR_4K_API_KEY: "YOUR_RADARR_4K_API_KEY" 20 | RADARR_4K_URL: "http://RADARR_4K_URL:PORT/api/v3" 21 | 22 | #SONARR CONFIG 23 | SONARR_API_KEY: "YOUR_SONARR_API_KEY" 24 | SONARR_URL: "http://SONARR_URL:PORT/api/v3" 25 | 26 | SONARR_4K_API_KEY: "SONARR_4K_API_KEY" 27 | SONARR_4K_URL: "http://SONARR_4K_URL:PORT/api/v3" 28 | 29 | #OVERSEERR or JELLYSEERR CONFIG 30 | OVERSEERR_API_KEY: "OVERRSEERR_OR_JELLYSEERR_API_KEY" 31 | OVERSEERR_URL: "http://OVERRSEERR_OR_JELLYSEERR_URL:PORT/api/v1" 32 | 33 | #PLEX CONFIG 34 | PLEX_TOKEN: "YOUR_PLEX_TOKEN" 35 | PLEX_HOST: "http://PLEX_SERVER_URL:PORT" 36 | ANIME_LIBRARY_NAME: "YOUR_ANIME_LIBRARY_NAME" 37 | MOVIES_LIBRARY_NAME: "YOUR_MOVIE_LIBRARY_NAME" 38 | SERIES_LIBRARY_NAME: "YOUR_TV_LIBRARY_NAME" 39 | SERVER_CLIENTID: "YOUR_PLEX_SERVER_CLIENT_ID" 40 | 41 | #SONARR WEBHOOK CONFIG 42 | SONARR_EP_TRACKING: "false" #Enable to use Sonarr webhook; disables TV logic from being executed via Media Available 43 | SONARR_TRACK_DELAY_SECONDS: 12 #Delay (in seconds) before triggering audio and subtitle track preferences to ensure that Plex metadata is refreshed, defaults to 10 if empty 44 | 45 | #GPT CONFIG 46 | #Replace the gpt model with the one you wish. 47 | #Bytesize, tokens, chuncks and delays can be left as is. 48 | ENABLE_GPT: "true" 49 | MODEL_GPT: "gpt-4o" 50 | OPEN_AI_API_KEY: "YOUR_OPEN_AI_KEY" 51 | MAX_REQUEST_BYTES: 2000 52 | MAX_TOKENS: 4000 53 | CHUNK_OVERLAP: 2 54 | REQUEST_DELAY: 2 55 | 56 | #Provide the locations Bazarr stores subtitles to use with GPT; else, leave empty 57 | MOVIE_PATH_MAPPING: "M:\\Movies\\1080p" #Bazarr subtitles path for GPT translation 58 | TV_PATH_MAPPING: "M:\\TV\\1080p" #Bazarr subtitles path for GPT translation 59 | 60 | #ADDITONAL OPTIONS CONFIG 61 | ENABLE_KOMETA: "true" 62 | KOMETA_CONFIG_PATH: CONFIG_PATH_TO_KOMETA 63 | ENABLE_MEDIA_AVAILABLE_HANDLING: "true" #Enable if you want to use auto-label, kometa overlays or audio preference 64 | ENABLE_AUDIO_PREF: "true" #Enable if you want to use user specific audio preference 65 | MONITOR_REQUESTS: "false" #Enable if you want to add label or set audio to media that's partially available 66 | CHECK_REQUEST_INTERVAL: 10 #Set the desired interval for monitor request to execute (in seconds) 67 | PORT: 8089 #Webhook port 68 | LANGUAGE_MAP: "{\"da\":\"Danish\",\"en\":\"English\",\"bg\":\"Bulgarian\",\"dansk\":\"Danish\",\"english\":\"English\",\"danske\":\"Danish\",\"eng\":\"English\"}" #Replace with your own keywords 69 | SYNC_KEYWORDS: "[\"sync\", \"out of sync\", \"messed up\", \"synchronization\"]" #Replace with your own keywords 70 | ADD_LABEL_KEYWORDS: "[\"add to library\", \"jeg vil se\", \"tilføj til bibliotek\", \"tilføj\"]" #Replace with your own keywords 71 | 72 | volumes: 73 | - M:\Movies\1080p:/mnt/movies 74 | - M:\TV\1080p:/mnt/tv 75 | - path/to/config:/mnt/usr #Storing user tokens and prefered audio 76 | - /var/run/docker.sock:/var/run/docker.sock #To trigger Kometa container 77 | 78 | restart: unless-stopped 79 | -------------------------------------------------------------------------------- /functions/Add-TagToMedia.ps1: -------------------------------------------------------------------------------- 1 | function Add-TagToMedia { 2 | param ( 3 | [string]$newTag, 4 | [array]$ratingKeys 5 | ) 6 | 7 | # Ensure Media Available handling is enabled 8 | if (-not $enableMediaAvailableHandling) { 9 | Log-Message -Type "WRN" -Message "Add-TagToMedia is disabled." 10 | return 11 | } 12 | 13 | # Ensure that Plex host and token are set 14 | if (-not $plexHost -or -not $plexToken) { 15 | Log-Message -Type "ERR" -Message "Plex host or token not set. Cannot proceed." 16 | return 17 | } 18 | 19 | foreach ($ratingKey in $ratingKeys) { 20 | # Construct the metadata URL for the media item 21 | $metadataUrl = "$plexHost/library/metadata/$ratingKey"+"?X-Plex-Token=$plexToken" 22 | Log-Message -Type "INF" -Message "Metadata URL: $metadataUrl" 23 | 24 | try { 25 | # Fetch the metadata for the media item 26 | $metadata = Invoke-RestMethod -Uri $metadataUrl -Method Get -ContentType "application/xml" 27 | } catch { 28 | Log-Message -Type "ERR" -Message "Error retrieving metadata for ratingKey ${ratingKey}: $_" 29 | continue 30 | } 31 | 32 | # Initialize an empty array for current labels 33 | $currentLabels = @() 34 | 35 | # Check if the media item has any labels 36 | if ($metadata.MediaContainer.Video.Label) { 37 | if ($metadata.MediaContainer.Video.Label -is [System.Array]) { 38 | # If multiple labels, collect them into an array 39 | $currentLabels = $metadata.MediaContainer.Video.Label | ForEach-Object { $_.tag } 40 | } else { 41 | # If only one label, convert it to an array 42 | $currentLabels = @($metadata.MediaContainer.Video.Label.tag) 43 | } 44 | } elseif ($metadata.MediaContainer.Directory.Label) { 45 | if ($metadata.MediaContainer.Directory.Label -is [System.Array]) { 46 | # If multiple labels, collect them into an array 47 | $currentLabels = $metadata.MediaContainer.Directory.Label | ForEach-Object { $_.tag } 48 | } else { 49 | # If only one label, convert it to an array 50 | $currentLabels = @($metadata.MediaContainer.Directory.Label.tag) 51 | } 52 | } 53 | 54 | # Add the new tag if it's not already present in the current labels 55 | if (-not ($currentLabels -contains $newTag)) { 56 | $currentLabels += $newTag 57 | } 58 | 59 | # Encode the labels for the Plex API update 60 | $encodedLabels = $currentLabels | ForEach-Object { "label[$($currentLabels.IndexOf($_))].tag.tag=" + [System.Uri]::EscapeDataString($_) } 61 | $encodedLabelsString = $encodedLabels -join "&" 62 | $updateUrl = "$plexHost/library/metadata/$ratingKey"+"?X-Plex-Token=$plexToken&$encodedLabelsString&label.locked=1" 63 | 64 | Log-Message -Type "INF" -Message "Update URL: $updateUrl" 65 | 66 | try { 67 | # Send the update request to Plex 68 | $responseResult = Invoke-RestMethod -Uri $updateUrl -Method Put 69 | Log-Message -Type "SUC" -Message "Label added to media item (RatingKey: $ratingKey): $($currentLabels -join ', ')" 70 | } catch { 71 | Log-Message -Type "ERR" -Message "Error adding label to media item (RatingKey: $ratingKey): $_" 72 | Log-Message -Type "INF" -Message "Request URL: $updateUrl" 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /functions/Contains-SyncKeyword.ps1: -------------------------------------------------------------------------------- 1 | function Contains-SyncKeyword { 2 | param ([string]$issueMessage, [array]$syncKeywords) 3 | $lowercaseMessage = $issueMessage.ToLower() 4 | foreach ($keyword in $syncKeywords) { 5 | if ($lowercaseMessage.Contains($keyword)) { 6 | return $true 7 | } 8 | } 9 | return $false 10 | } 11 | -------------------------------------------------------------------------------- /functions/Enqueue-Payload.ps1: -------------------------------------------------------------------------------- 1 | function Enqueue-Payload { 2 | param ([Parameter(Mandatory=$true)] [string]$Payload) 3 | $queue.Enqueue($Payload) 4 | } -------------------------------------------------------------------------------- /functions/Extract-LanguageCodeFromPath.ps1: -------------------------------------------------------------------------------- 1 | function Extract-LanguageCodeFromPath { 2 | param ([string]$subtitlePath) 3 | $regex = [regex]"\.(?[a-z]{2,3})(\.hi)?\.srt$" 4 | $match = $regex.Match($subtitlePath) 5 | if ($match.Success) { 6 | return $match.Groups["lang"].Value 7 | } else { 8 | return $null 9 | } 10 | } -------------------------------------------------------------------------------- /functions/Extract-Offset.ps1: -------------------------------------------------------------------------------- 1 | function Extract-Offset { 2 | param ( 3 | [string]$message 4 | ) 5 | if ($message -match "offset\s+(\d+)") { 6 | $offsetValue = [int]$matches[1] 7 | if ($offsetValue -ge 0 -and $offsetValue -le 60) { 8 | return 60 9 | } elseif ($offsetValue -ge 61 -and $offsetValue -le 120) { 10 | return 120 11 | } elseif ($offsetValue -ge 121 -and $offsetValue -le 300) { 12 | return 300 13 | } elseif ($offsetValue -ge 301 -and $offsetValue -le 600) { 14 | return 600 15 | } else { 16 | return $offsetValue 17 | } 18 | } 19 | return $null 20 | } 21 | -------------------------------------------------------------------------------- /functions/Generate-UserAudioPreferences.ps1: -------------------------------------------------------------------------------- 1 | function Generate-UserAudioPreferences { 2 | $tokenFile = "/mnt/usr/user_tokens.json" 3 | $audioPrefFile = "/mnt/usr/user_audio_pref.json" 4 | 5 | if (-not (Test-Path $tokenFile)) { 6 | Log-Message -Type "WRN" -Message "User token file not found: $tokenFile" 7 | return 8 | } 9 | 10 | try { 11 | $userTokens = Get-Content -Path $tokenFile | ConvertFrom-Json -AsHashtable 12 | } catch { 13 | Log-Message -Type "ERR" -Message "Error reading user token file: $_" 14 | return 15 | } 16 | 17 | $defaultPreferences = @{} 18 | 19 | # If audio preference file exists, load existing preferences 20 | if (Test-Path $audioPrefFile) { 21 | try { 22 | $existingPreferences = Get-Content -Path $audioPrefFile | ConvertFrom-Json -AsHashtable 23 | if ($existingPreferences -is [hashtable]) { 24 | $defaultPreferences = $existingPreferences 25 | } 26 | } catch { 27 | Log-Message -Type "ERR" -Message "Error reading audio preference file, resetting..." 28 | $defaultPreferences = @{} 29 | } 30 | } 31 | 32 | # Compare number of usernames in user_tokens.json and user_audio_pref.json 33 | $userTokenCount = $userTokens.Keys.Count 34 | $audioPrefCount = $defaultPreferences.Keys.Count 35 | 36 | if ($userTokenCount -ne $audioPrefCount) { 37 | foreach ($user in $userTokens.Keys) { 38 | $plexUsername = $user 39 | 40 | if (-not $defaultPreferences.ContainsKey($plexUsername)) { 41 | # Assign a default preference with prioritized list 42 | $defaultPreferences[$plexUsername] = @{ 43 | "preferred" = @( 44 | @{ "languageCode" = "eng"; "codec" = "EAC3"; "channels" = 6 }, 45 | @{ "languageCode" = "eng"; "codec" = "AAC"; "channels" = 2 } 46 | ) 47 | "fallback" = @{ 48 | "matchChannels" = $true 49 | } 50 | } 51 | Log-Message -Type "INF" -Message "Added default audio preference for user: $plexUsername" 52 | } 53 | } 54 | 55 | # Save updated preferences 56 | $defaultPreferences | ConvertTo-Json -Depth 10 | Set-Content -Path $audioPrefFile 57 | Log-Message -Type "SUC" -Message "User audio preferences updated and saved to $audioPrefFile" 58 | } else { 59 | Log-Message -Type "INF" -Message "User audio preferences are up to date. No changes needed." 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /functions/Generate-UserSubtitlePreferences.ps1: -------------------------------------------------------------------------------- 1 | function Generate-UserSubtitlePreferences { 2 | $tokenFile = "/mnt/usr/user_tokens.json" 3 | $subtitlePrefFile = "/mnt/usr/user_subs_pref.json" 4 | 5 | if (-not (Test-Path $tokenFile)) { 6 | Log-Message -Type "WRN" -Message "User token file not found: $tokenFile" 7 | return 8 | } 9 | 10 | try { 11 | $userTokens = Get-Content -Path $tokenFile | ConvertFrom-Json -AsHashtable 12 | } catch { 13 | Log-Message -Type "ERR" -Message "Error reading user token file: $_" 14 | return 15 | } 16 | 17 | $defaultPreferences = @{} 18 | 19 | # If subtitle preference file exists, load existing preferences 20 | if (Test-Path $subtitlePrefFile) { 21 | try { 22 | $existingPreferences = Get-Content -Path $subtitlePrefFile | ConvertFrom-Json -AsHashtable 23 | if ($existingPreferences -is [hashtable]) { 24 | $defaultPreferences = $existingPreferences 25 | } 26 | } catch { 27 | Log-Message -Type "ERR" -Message "Error reading subtitle preference file, resetting..." 28 | $defaultPreferences = @{} 29 | } 30 | } 31 | 32 | # Compare number of usernames in user_tokens.json and user_subs_pref.json 33 | $userTokenCount = $userTokens.Count 34 | $subtitlePrefCount = $defaultPreferences.Count 35 | 36 | if ($userTokenCount -ne $subtitlePrefCount) { 37 | foreach ($plexUsername in $userTokens.Keys) { 38 | if (-not $defaultPreferences.ContainsKey($plexUsername)) { 39 | # Assign a default preference with prioritized list 40 | $defaultPreferences[$plexUsername] = @{ 41 | "preferred" = @( 42 | @{ "languageCode" = "eng"; "forced" = $true; "hearingImpaired" = $true; "codec" = "srt" } # Prefer forced SRT subtitles 43 | ); 44 | "fallback" = @{ 45 | "enabled" = $true; 46 | "preferences" = @( 47 | @{ "languageCode" = "eng"; "forced" = $false; "hearingImpaired" = $true; "codec" = "srt" } # Default fallback 48 | ) 49 | } 50 | } 51 | Log-Message -Type "INF" -Message "Added default subtitle preference for user: $plexUsername" 52 | } 53 | } 54 | 55 | # Save updated preferences 56 | $defaultPreferences | ConvertTo-Json -Depth 10 | Set-Content -Path $subtitlePrefFile -Encoding utf8 57 | Log-Message -Type "SUC" -Message "User subtitle preferences updated and saved to $subtitlePrefFile" 58 | } else { 59 | Log-Message -Type "INF" -Message "User subtitle preferences are up to date. No changes needed." 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /functions/Get-BazarrEpisodeSubtitlePath.ps1: -------------------------------------------------------------------------------- 1 | function Get-BazarrEpisodeSubtitlePath { 2 | param ([string]$seriesId, [string]$episodeId, [string]$languageName, [bool]$hearingImpaired, [string]$bazarrApiKey, [string]$bazarrUrl) 3 | $url = "$bazarrUrl/episodes?seriesid%5B%5D=$seriesId&episodeid%5B%5D=$episodeId&apikey=$bazarrApiKey" 4 | try { 5 | $response = Invoke-RestMethod -Uri $url -Method Get 6 | foreach ($episode in $response.data) { 7 | foreach ($subtitle in $episode.subtitles) { 8 | if ($subtitle.name -eq $languageName -and $subtitle.hi -eq $hearingImpaired) { 9 | return $subtitle.path 10 | } 11 | } 12 | } 13 | return $null 14 | } catch { 15 | return $null 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /functions/Get-BazarrLanguageCode.ps1: -------------------------------------------------------------------------------- 1 | function Get-BazarrLanguageCode { 2 | param ( 3 | [string]$languageName, 4 | [string]$bazarrUrl, 5 | [string]$bazarrApiKey 6 | ) 7 | 8 | $url = "$bazarrUrl/system/languages" 9 | try { 10 | $response = Invoke-RestMethod -Uri $url -Method Get -Headers @{ "X-API-KEY" = $bazarrApiKey } 11 | $language = $response | Where-Object { $_.name -eq $languageName } 12 | return $language.code2 13 | } catch { 14 | Write-Host "Failed to fetch languages from Bazarr: $_" 15 | return $null 16 | } 17 | } -------------------------------------------------------------------------------- /functions/Get-BazarrMovieSubtitlePath.ps1: -------------------------------------------------------------------------------- 1 | function Get-BazarrMovieSubtitlePath { 2 | param ( 3 | [string]$radarrId, 4 | [string]$languageName, 5 | [bool]$hearingImpaired, 6 | [bool]$forced, 7 | [string]$bazarrApiKey, 8 | [string]$bazarrUrl 9 | ) 10 | 11 | $url = "$bazarrUrl/movies?start=0&length=-1&radarrid%5B%5D=$radarrId&apikey=$bazarrApiKey" 12 | try { 13 | $response = Invoke-RestMethod -Uri $url -Method Get 14 | foreach ($movie in $response.data) { 15 | foreach ($subtitle in $movie.subtitles) { 16 | if ($subtitle.name -eq $languageName -and 17 | $subtitle.hi -eq $hearingImpaired -and 18 | $subtitle.forced -eq $forced) { 19 | return $subtitle.path 20 | } 21 | } 22 | } 23 | return $null 24 | } catch { 25 | return $null 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /functions/Get-GPTTranslationChunked.ps1: -------------------------------------------------------------------------------- 1 | function Get-GPTTranslationChunked { 2 | param ( 3 | [string]$text, 4 | [string]$sourceLang, 5 | [string]$targetLang 6 | ) 7 | 8 | $openAiUrl = "https://api.openai.com/v1/chat/completions" 9 | $maxBytesPerRequest = [int]$env:MAX_REQUEST_BYTES 10 | $chunkOverlap = [int]$env:CHUNK_OVERLAP 11 | $delayBetweenRequests = [int]$env:REQUEST_DELAY 12 | 13 | $lines = $text -split "(?<=\n)" 14 | $chunks = @() 15 | $currentChunk = @() 16 | $currentByteCount = 0 17 | 18 | foreach ($line in $lines) { 19 | $lineByteCount = [System.Text.Encoding]::UTF8.GetByteCount($line) 20 | $currentByteCount += $lineByteCount 21 | $currentChunk += $line 22 | 23 | if ($currentByteCount -ge $maxBytesPerRequest) { 24 | $chunks += $currentChunk -join "" 25 | $currentChunk = @() 26 | $currentByteCount = 0 27 | } 28 | } 29 | 30 | if ($currentChunk.Count -gt 0) { 31 | $chunks += $currentChunk -join "" 32 | } 33 | 34 | $translatedText = "" 35 | $chunksRemaining = $chunks.Count 36 | $estimatedTotalTime = $chunksRemaining * $delayBetweenRequests 37 | Write-Host "Subtitle file is being translated by GPT..." 38 | Write-Host "Estimated time: approximately $estimatedTotalTime seconds for $chunksRemaining chunks.`n" 39 | 40 | for ($i = 0; $i -lt $chunks.Count; $i++) { 41 | $chunk = $chunks[$i] 42 | if (-not [string]::IsNullOrWhiteSpace($chunk)) { 43 | $chunkByteSize = [System.Text.Encoding]::UTF8.GetByteCount($chunk) 44 | $remainingChunks = $chunks.Count - $i - 1 45 | $remainingTime = $remainingChunks * $delayBetweenRequests 46 | Write-Host "Translating chunk $($i + 1) of $($chunks.Count) ($chunkByteSize bytes)... Estimated time remaining: $remainingTime seconds" 47 | 48 | $promptText = [string]::Format( 49 | "Translate from {0} to {1}. Keep all timestamps and sentence structures exactly the same. Do not add any extra comments, metadata, or formatting. Only translate the spoken lines into {1}, and preserve the subtitle formatting:", 50 | $sourceLang, $targetLang 51 | ) + "`n" + $chunk 52 | 53 | $requestBody = @{ 54 | "model" = $modelGPT 55 | "messages" = @( 56 | @{ 57 | "role" = "system" 58 | "content" = "You are a helpful assistant that translates subtitles while preserving the subtitle format." 59 | }, 60 | @{ 61 | "role" = "user" 62 | "content" = $promptText 63 | } 64 | ) 65 | "max_tokens" = $maxTokens 66 | } 67 | 68 | $jsonBody = $requestBody | ConvertTo-Json -Depth 3 -Compress 69 | $utf8Body = [System.Text.Encoding]::UTF8.GetBytes($jsonBody) 70 | 71 | try { 72 | $response = Invoke-RestMethod -Uri $openAiUrl -Method Post -Headers @{ 73 | "Authorization" = "Bearer $openAiApiKey" 74 | "Content-Type" = "application/json" 75 | } -Body $utf8Body 76 | 77 | $translatedText += $response.choices[0].message.content.Trim() + "`n" 78 | Start-Sleep -Seconds $delayBetweenRequests 79 | } 80 | catch { 81 | Write-Host "Failed to call OpenAI GPT for chunk $($i + 1): $_" 82 | return $null 83 | } 84 | } 85 | } 86 | 87 | $translatedText = $translatedText -replace '```plaintext', '' -replace '```', '' -replace "\r?\n{2,}", "`n" 88 | $translatedText = $translatedText -replace "(\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3})\r?\n\1", "$1" 89 | 90 | return $translatedText 91 | } 92 | -------------------------------------------------------------------------------- /functions/Get-PlexLibraryId.ps1: -------------------------------------------------------------------------------- 1 | function Get-PlexLibraryIds { 2 | param ( 3 | [string]$plexHost, 4 | [string]$plexToken, 5 | [pscustomobject]$libraryCategories 6 | ) 7 | 8 | $url = "$plexHost/library/sections/all"+"?X-Plex-Token=$plexToken" 9 | 10 | try { 11 | $response = Invoke-RestMethod -Uri $url -Method Get -ContentType "application/xml" 12 | $libraries = $response.MediaContainer.Directory 13 | 14 | if (-not $libraries) { 15 | Log-Message -Type "ERR" -Message "No libraries found in Plex. Ensure the Plex server is running and accessible." 16 | return @{} # Return an empty object instead of $null to avoid script crashes 17 | } 18 | 19 | $libraryIds = @{} 20 | 21 | foreach ($category in $libraryCategories.Keys) { 22 | $filteredLibraryNames = $libraryCategories[$category] | Where-Object { $_ -and $_.Trim() -ne "" } 23 | 24 | if ($filteredLibraryNames.Count -lt $libraryCategories[$category].Count) { 25 | Log-Message -Type "WRN" -Message "One or more library names were null or empty in category '$category' and have been ignored." 26 | } 27 | 28 | # Define correct Plex library types based on category 29 | $categoryType = switch ($category) { 30 | "Movies" { "movie" } 31 | "TV" { "show" } 32 | "Anime" { "show" } # Anime is categorized as TV in Plex 33 | Default { "" } 34 | } 35 | 36 | foreach ($libraryName in $filteredLibraryNames) { 37 | # Get all matching libraries with the correct type 38 | $matchingLibraries = $libraries | Where-Object { $_.title -ieq $libraryName -and $_.type -eq $categoryType } 39 | 40 | if ($matchingLibraries) { 41 | # Ensure $libraryIds[$category] is initialized as an array before adding IDs 42 | if (-not $libraryIds.ContainsKey($category)) { 43 | $libraryIds[$category] = @() 44 | } 45 | $libraryIds[$category] += $matchingLibraries.key 46 | 47 | Log-Message -Type "SUC" -Message "Library '$libraryName' found in category '$category' with IDs: $($matchingLibraries.key -join ', ')" 48 | } else { 49 | Log-Message -Type "ERR" -Message "Library '$libraryName' of type '$category' not found." 50 | if (-not $libraryIds.ContainsKey($category)) { 51 | $libraryIds[$category] = @() 52 | } 53 | } 54 | } 55 | } 56 | 57 | # Check if any library IDs were found 58 | if (-not $libraryIds.Keys.Count) { 59 | Log-Message -Type "WRN" -Message "No valid library IDs were found. Skipping." 60 | return @{} 61 | } 62 | 63 | return $libraryIds 64 | } catch { 65 | Log-Message -Type "ERR" -Message "Error fetching Plex library sections: $_" 66 | return @{} # Return an empty object instead of $null to prevent crashes 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /functions/Get-PlexUserTokens.ps1: -------------------------------------------------------------------------------- 1 | function Get-PlexUserTokens { 2 | param ( 3 | [string]$plexToken, 4 | [string]$plexClientId 5 | ) 6 | 7 | $tokenFile = "/mnt/usr/user_tokens.json" 8 | $plexUsersUrl = "https://plex.tv/api/servers/$plexClientId/shared_servers?X-Plex-Token=$plexToken" 9 | $homeUsersUrl = "https://plex.tv/api/v2/home/users?X-Plex-Client-Identifier=$plexClientId&X-Plex-Token=$plexToken" 10 | 11 | # Load existing user token data 12 | if (Test-Path $tokenFile) { 13 | try { 14 | $existingUserTokens = Get-Content -Raw -Path $tokenFile | ConvertFrom-Json -AsHashtable 15 | } catch { 16 | Log-Message -Type "ERR" -Message "Error reading user_tokens.json. Resetting..." 17 | $existingUserTokens = @{} 18 | } 19 | } else { 20 | $existingUserTokens = @{} 21 | } 22 | 23 | try { 24 | # Fetch managed accounts (with potentially missing usernames) 25 | $homeUsersResponse = Invoke-RestMethod -Uri $homeUsersUrl -Method Get -Headers @{ 26 | "Accept" = "application/xml" 27 | } 28 | $homeUsersXml = [xml]$homeUsersResponse.OuterXml 29 | 30 | # Get the first user (admin account) 31 | $adminUser = $homeUsersXml.home.users.user[0] 32 | if ($adminUser) { 33 | $adminUsername = $adminUser.username 34 | if ($adminUsername) { 35 | # Add the admin user and token 36 | $existingUserTokens[$adminUsername] = $plexToken 37 | Log-Message -Type "SUC" -Message "Added admin token for user: $adminUsername" 38 | } 39 | } 40 | 41 | # Fetch shared users from Plex API 42 | $response = Invoke-RestMethod -Uri $plexUsersUrl -Method Get -Headers @{ 43 | "Accept" = "application/xml" 44 | } 45 | $xmlResponse = [xml]$response.OuterXml 46 | $sharedServers = $xmlResponse.MediaContainer.SharedServer 47 | 48 | # Create a hashtable to store tokens 49 | $userTokens = @{} 50 | 51 | foreach ($server in $sharedServers) { 52 | $plexUsername = $server.username 53 | $userAuthToken = $server.accessToken 54 | $userId = $server.userID 55 | 56 | # Handle blank username (managed accounts) 57 | if (-not $plexUsername -and $userId) { 58 | $managedUser = $homeUsersXml.home.users.user | Where-Object { $_.id -eq $userId } 59 | if ($managedUser) { 60 | $plexUsername = $managedUser.title 61 | } 62 | } 63 | 64 | if ($userAuthToken) { 65 | $userTokens[$plexUsername] = $userAuthToken 66 | Log-Message -Type "SUC" -Message "Retrieved token for user: $plexUsername" 67 | } else { 68 | Log-Message -Type "WRN" -Message "No token found for user: $plexUsername" 69 | } 70 | } 71 | 72 | # Merge admin token and shared tokens 73 | $updatedTokens = @{} 74 | foreach ($key in $existingUserTokens.Keys) { 75 | $updatedTokens[$key] = $existingUserTokens[$key] 76 | } 77 | foreach ($key in $userTokens.Keys) { 78 | $updatedTokens[$key] = $userTokens[$key] 79 | } 80 | 81 | # Update the JSON file if the count has changed 82 | $newUserCount = $updatedTokens.Keys.Count 83 | $existingUserCount = $existingUserTokens.Keys.Count 84 | 85 | if ($newUserCount -ne $existingUserCount) { 86 | $updatedTokens | ConvertTo-Json -Depth 10 | Set-Content -Path $tokenFile 87 | Log-Message -Type "WRN" -Message "User count changed ($existingUserCount → $newUserCount). Updated user tokens in $tokenFile" 88 | } else { 89 | Log-Message -Type "INF" -Message "No change in user count ($existingUserCount users). Skipping update." 90 | } 91 | } catch { 92 | Log-Message -Type "ERR" -Message "Error retrieving Plex user tokens: $($_.Exception.Message)" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /functions/Get-RadarrMovieDetails.ps1: -------------------------------------------------------------------------------- 1 | function Get-RadarrMovieDetails { 2 | param ([string]$tmdbId, [string]$radarrApiKey, [string]$radarrUrl) 3 | $url = "$radarrUrl/movie?tmdbId=$tmdbId&apikey=$radarrApiKey" 4 | try { 5 | $response = Invoke-RestMethod -Uri $url -Method Get 6 | return $response 7 | } catch { 8 | return $null 9 | } 10 | } -------------------------------------------------------------------------------- /functions/Get-SonarrEpisodeDetails.ps1: -------------------------------------------------------------------------------- 1 | function Get-SonarrEpisodeDetails { 2 | param ([string]$seriesId, [int]$seasonNumber, [int]$episodeNumber, [string]$sonarrApiKey, [string]$sonarrUrl) 3 | $url = "$sonarrUrl/episode?seriesId=$seriesId&seasonNumber=$seasonNumber&includeImages=false&apikey=$sonarrApiKey" 4 | try { 5 | $response = Invoke-RestMethod -Uri $url -Method Get 6 | foreach ($episode in $response) { 7 | if ($episode.seasonNumber -eq $seasonNumber -and $episode.episodeNumber -eq $episodeNumber) { 8 | return $episode 9 | } 10 | } 11 | return $null 12 | } catch { 13 | return $null 14 | } 15 | } -------------------------------------------------------------------------------- /functions/Get-SonarrEpisodesBySeason.ps1: -------------------------------------------------------------------------------- 1 | function Get-SonarrEpisodesBySeason { 2 | param ([string]$seriesId, [int]$seasonNumber, [string]$sonarrApiKey, [string]$sonarrUrl) 3 | $url = "$sonarrUrl/episode?seriesId=$seriesId&seasonNumber=$seasonNumber&includeImages=false&apikey=$sonarrApiKey" 4 | try { 5 | $response = Invoke-RestMethod -Uri $url -Method Get 6 | return $response 7 | } catch { 8 | return $null 9 | } 10 | } -------------------------------------------------------------------------------- /functions/Get-SonarrSeriesId.ps1: -------------------------------------------------------------------------------- 1 | function Get-SonarrSeriesId { 2 | param ([string]$tvdbId, [string]$sonarrApiKey, [string]$sonarrUrl) 3 | $url = "$sonarrUrl/series?tvdbId=$tvdbId&includeSeasonImages=false&apikey=$sonarrApiKey" 4 | try { 5 | $response = Invoke-RestMethod -Uri $url -Method Get 6 | return $response.id 7 | } catch { 8 | return $null 9 | } 10 | } -------------------------------------------------------------------------------- /functions/Get-SubtitlePath.ps1: -------------------------------------------------------------------------------- 1 | function Get-SubtitlePath { 2 | param ( 3 | [string]$subtitlePath, 4 | [string]$mediaType # Should be either 'movie' or 'tv' 5 | ) 6 | 7 | # Retrieve and sanitize environment variables 8 | $moviePathMapping = $env:MOVIE_PATH_MAPPING -replace "\\", "/" -replace "\s+$", "" 9 | $tvPathMapping = $env:TV_PATH_MAPPING -replace "\\", "/" -replace "\s+$", "" 10 | 11 | # Normalize subtitle path 12 | $subtitlePath = $subtitlePath -replace "\\", "/" 13 | 14 | # Log initial paths 15 | Log-Message -Type "INF" -Message "Original Subtitle Path from Bazarr: '$subtitlePath'" 16 | Log-Message -Type "DBG" -Message "Configured Movie Path Mapping: '$moviePathMapping'" 17 | Log-Message -Type "DBG" -Message "Configured TV Path Mapping: '$tvPathMapping'" 18 | 19 | # Path matching logic (simplified) 20 | if ($mediaType -eq 'movie' -and $subtitlePath.StartsWith($moviePathMapping)) { 21 | $subtitlePath = $subtitlePath -replace [regex]::Escape($moviePathMapping), "/mnt/movies" 22 | Log-Message -Type "SUC" -Message "Mapped Movie Subtitle Path: '$subtitlePath'" 23 | } elseif ($mediaType -eq 'tv' -and $subtitlePath.StartsWith($tvPathMapping)) { 24 | $subtitlePath = $subtitlePath -replace [regex]::Escape($tvPathMapping), "/mnt/tv" 25 | Log-Message -Type "SUC" -Message "Mapped TV Subtitle Path: '$subtitlePath'" 26 | } else { 27 | Log-Message -Type "ERR" -Message "No matching path found for media type '$mediaType'." 28 | } 29 | 30 | return $subtitlePath 31 | } 32 | -------------------------------------------------------------------------------- /functions/Get-SubtitleText.ps1: -------------------------------------------------------------------------------- 1 | function Get-SubtitleText { 2 | param ( 3 | [string]$subtitlePath 4 | ) 5 | 6 | # Retrieve and normalize path mappings 7 | $moviePathMapping = $env:MOVIE_PATH_MAPPING -replace "\\", "/" 8 | $tvPathMapping = $env:TV_PATH_MAPPING -replace "\\", "/" 9 | $subtitlePath = $subtitlePath -replace "\\", "/" 10 | 11 | Log-Message -Type "INF" -Message "Received Subtitle Path: '$subtitlePath'" 12 | Log-Message -Type "DBG" -Message "Configured Movie Path Mapping: '$moviePathMapping'" 13 | Log-Message -Type "DBG" -Message "Configured TV Path Mapping: '$tvPathMapping'" 14 | 15 | # Match and replace paths 16 | if ($subtitlePath.StartsWith($moviePathMapping)) { 17 | $subtitlePath = $subtitlePath -replace [regex]::Escape($moviePathMapping), "/mnt/movies" 18 | Log-Message -Type "SUC" -Message "Mapped Subtitle Path to Movie Container Path: '$subtitlePath'" 19 | } elseif ($subtitlePath.StartsWith($tvPathMapping)) { 20 | $subtitlePath = $subtitlePath -replace [regex]::Escape($tvPathMapping), "/mnt/tv" 21 | Log-Message -Type "SUC" -Message "Mapped Subtitle Path to TV Container Path: '$subtitlePath'" 22 | } else { 23 | Log-Message -Type "ERR" -Message "No matching path found for the given subtitle path." 24 | } 25 | 26 | # Debugging before checking file existence 27 | Log-Message -Type "DBG" -Message "Checking file existence at path: '$subtitlePath'" 28 | 29 | # Verify file existence 30 | if (Test-Path -LiteralPath $subtitlePath) { 31 | Log-Message -Type "INF" -Message "Subtitle file exists: '$subtitlePath'" 32 | try { 33 | $subtitleText = Get-Content -LiteralPath $subtitlePath | Out-String 34 | return $subtitleText 35 | } catch { 36 | Log-Message -Type "ERR" -Message "Error reading subtitle file: $_" 37 | return $null 38 | } 39 | } else { 40 | Log-Message -Type "ERR" -Message "Subtitle file does NOT exist: '$subtitlePath'" 41 | return $null 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /functions/Handle-MediaAvailable.ps1: -------------------------------------------------------------------------------- 1 | function Handle-MediaAvailable { 2 | param ([psobject]$payload) 3 | 4 | # Ensure Media Available handling is enabled 5 | if (-not $enableMediaAvailableHandling) { 6 | Log-Message -Type "WRN" -Message "Media Available handling is disabled." 7 | return 8 | } 9 | 10 | # Extract relevant information from the payload 11 | $mediaType = $payload.media.media_type 12 | $tmdbId = $payload.media.tmdbId 13 | $tvdbId = $payload.media.tvdbId 14 | $plexUsername = $payload.request.requestedBy_username 15 | 16 | Log-Message -Type "INF" -Message "Processing $mediaType request for Plex user: $plexUsername with TMDB ID: $tmdbId" 17 | 18 | # Ensure that Plex host and token are set 19 | if (-not $plexHost -or -not $plexToken) { 20 | Log-Message -Type "ERR" -Message "Plex host or token not set. Cannot proceed." 21 | return 22 | } 23 | 24 | # Handling movies 25 | if ($mediaType -eq "movie") { 26 | $sectionIds = $moviesSectionIds 27 | $mediaTypeForSearch = "1" # Movie type for Plex API 28 | 29 | # API call to get movie title and release date 30 | $movieLookupUrl = "$overseerrUrl/movie/$tmdbId" 31 | Log-Message -Type "INF" -Message "Movie Lookup URL: $movieLookupUrl" 32 | try { 33 | $headers = @{ 34 | 'X-Api-Key' = $overseerrApiKey 35 | } 36 | $movieDetails = Invoke-RestMethod -Uri $movieLookupUrl -Method Get -Headers $headers 37 | $title = $movieDetails.title 38 | $releaseDate = $movieDetails.releaseDate 39 | $year = $releaseDate.Split('-')[0] # Extract year from release date 40 | } catch { 41 | Log-Message -Type "ERR" -Message "Error fetching movie details: $_" 42 | return 43 | } 44 | } 45 | 46 | # Skip TV logic if Sonarr handler is enabled 47 | elseif ($mediaType -eq "tv" -and $enableSonarrEpisodeHandler) { 48 | Log-Message -Type "WRN" -Message "TV handling skipped due to Sonarr episode handler being enabled." 49 | return 50 | } 51 | 52 | # Handling TV shows (including anime) 53 | elseif ($mediaType -eq "tv") { 54 | $sectionIds = $seriesSectionIds 55 | $mediaTypeForSearch = "2" # TV type for Plex API 56 | 57 | # API call to check seriesType and get series title and year 58 | if ($null -ne $tmdbId) { 59 | $seriesLookupUrl = "$overseerrUrl/service/sonarr/lookup/$tmdbId" 60 | Log-Message -Type "INF" -Message "Series Lookup URL (TMDB): $seriesLookupUrl" 61 | } elseif ($null -ne $tvdbId) { 62 | $seriesLookupUrl = "$overseerrUrl/service/sonarr/lookup/$tvdbId?type=tvdb" 63 | Log-Message -Type "INF" -Message "Series Lookup URL (TVDB): $seriesLookupUrl" 64 | } else { 65 | Log-Message -Type "ERR" -Message "Both TMDB ID and TVDB ID are missing." 66 | return 67 | } 68 | 69 | try { 70 | $headers = @{ 71 | 'X-Api-Key' = $overseerrApiKey 72 | } 73 | $seriesDetails = Invoke-RestMethod -Uri $seriesLookupUrl -Method Get -Headers $headers 74 | $matchedSeries = $seriesDetails | Where-Object { $_.tmdbId -eq $tmdbId -or $_.tvdbId -eq $tvdbId } 75 | if ($null -eq $matchedSeries) { 76 | Log-Message -Type "ERR" -Message "No matching series found for tmdbId: $tmdbId or tvdbId: $tvdbId" 77 | return 78 | } 79 | $title = $matchedSeries.title 80 | $year = $matchedSeries.year 81 | if ($matchedSeries.seriesType -eq "anime") { 82 | $sectionIds = $animeSectionIds 83 | } 84 | } catch { 85 | Log-Message -Type "ERR" -Message "Error fetching series details: $_" 86 | return 87 | } 88 | } else { 89 | Log-Message -Type "ERR" -Message "Unsupported media type: $mediaType" 90 | return 91 | } 92 | 93 | Log-Message -Type "SUC" -Message "Extracted Title: $title" 94 | Log-Message -Type "SUC" -Message "Extracted Year: $year" 95 | 96 | # Search for the media item in Plex using the title and year across multiple section IDs 97 | $mediaItem = $null 98 | foreach ($sectionId in $sectionIds) { 99 | $searchUrl = "$plexHost/library/sections/$sectionId/all?type=$mediaTypeForSearch&title=" + [System.Uri]::EscapeDataString($title) + "&year=$year&X-Plex-Token=$plexToken" 100 | 101 | try { 102 | $mediaItems = Invoke-RestMethod -Uri $searchUrl -Method Get -ContentType "application/xml" 103 | 104 | if ($mediaType -eq "movie") { 105 | $mediaItem = $mediaItems.MediaContainer.Video | Where-Object { $_.title -eq $title -and $_.year -eq $year } 106 | } elseif ($mediaType -eq "tv") { 107 | $mediaItem = $mediaItems.MediaContainer.Directory | Where-Object { $_.title -eq $title -and $_.year -eq $year } 108 | } 109 | 110 | if ($mediaItem) { 111 | break # Stop searching once we find the media 112 | } 113 | } catch { 114 | Log-Message -Type "ERR" -Message "Error contacting Plex server for section ID ${sectionId}: $_" 115 | } 116 | } 117 | 118 | if ($null -eq $mediaItem) { 119 | Log-Message -Type "ERR" -Message "Media item not found in any section." 120 | return 121 | } 122 | 123 | # Ensure extracted rating keys are stored as an array 124 | $ratingKeys = @() 125 | if ($mediaItem -is [System.Array]) { 126 | $ratingKeys = $mediaItem.ratingKey 127 | } else { 128 | $ratingKeys += $mediaItem.ratingKey 129 | } 130 | 131 | Log-Message -Type "SUC" -Message "Extracted Rating Keys: $($ratingKeys -join ', ')" 132 | 133 | # Call Add-TagToMedia with the appropriate parameters 134 | Add-TagToMedia -newTag $plexUsername -ratingKeys $ratingKeys 135 | 136 | # Apply preferred audio and subtitle settings for each rating key 137 | if ($enableAudioPref) { 138 | foreach ($ratingKey in $ratingKeys) { 139 | Set-AudioTrack -ratingKey $ratingKey -plexUsername $plexUsername 140 | Set-SubtitleTrack -ratingKey $ratingKey -plexUsername $plexUsername 141 | } 142 | } 143 | 144 | } 145 | -------------------------------------------------------------------------------- /functions/Handle-OtherIssue.ps1: -------------------------------------------------------------------------------- 1 | function Handle-OtherIssue { 2 | param ([psobject]$payload) 3 | 4 | $subject = $payload.subject 5 | $label = $payload.issue.reportedBy_username 6 | $issueType = $payload.issue.issue_type 7 | $message = $payload.message 8 | $mediaType = $payload.media.media_type 9 | 10 | Write-Host "Extracted Label: $label" 11 | 12 | if ($mediaType -eq "movie") { 13 | $sectionId = $moviesSectionId 14 | $mediaTypeForSearch = "1" 15 | 16 | # API call to get movie title and release date 17 | $tmdbId = $payload.media.tmdbId 18 | $movieLookupUrl = "$overseerrUrl/movie/$tmdbId" 19 | Write-Host "Movie Lookup URL: $movieLookupUrl" 20 | try { 21 | $headers = @{ 22 | 'X-Api-Key' = $overseerrApiKey 23 | } 24 | $movieDetails = Invoke-RestMethod -Uri $movieLookupUrl -Method Get -Headers $headers 25 | $title = $movieDetails.title 26 | $releaseDate = $movieDetails.releaseDate 27 | $year = $releaseDate.Split('-')[0] # Extract year from release date 28 | } catch { 29 | Write-Host "Error fetching movie details: $_" 30 | return 31 | } 32 | } elseif ($mediaType -eq "tv") { 33 | $sectionId = $seriesSectionId 34 | $mediaTypeForSearch = "2" 35 | 36 | # API call to check seriesType and get series title and year 37 | $tmdbId = $payload.media.tmdbId 38 | $tvdbId = $payload.media.tvdbId 39 | 40 | if ($null -ne $tmdbId) { 41 | $seriesLookupUrl = "$overseerrUrl/service/sonarr/lookup/$tmdbId" 42 | Write-Host "Series Lookup URL (TMDB): $seriesLookupUrl" 43 | } elseif ($null -ne $tvdbId) { 44 | $seriesLookupUrl = "$overseerrUrl/service/sonarr/lookup/$tvdbId?type=tvdb" 45 | Write-Host "Series Lookup URL (TVDB): $seriesLookupUrl" 46 | } else { 47 | Write-Host "Both TMDB ID and TVDB ID are missing." 48 | return 49 | } 50 | 51 | try { 52 | $headers = @{ 53 | 'X-Api-Key' = $overseerrApiKey 54 | } 55 | $seriesDetails = Invoke-RestMethod -Uri $seriesLookupUrl -Method Get -Headers $headers 56 | $matchedSeries = $seriesDetails | Where-Object { $_.tmdbId -eq $tmdbId -or $_.tvdbId -eq $tvdbId } 57 | if ($null -eq $matchedSeries) { 58 | Write-Host "No matching series found for tmdbId: $tmdbId or tvdbId: $tvdbId" 59 | return 60 | } 61 | $title = $matchedSeries.title 62 | $year = $matchedSeries.year 63 | if ($matchedSeries.seriesType -eq "anime") { 64 | $sectionId = $animeSectionId 65 | } 66 | } catch { 67 | Write-Host "Error fetching series details: $_" 68 | return 69 | } 70 | } else { 71 | Write-Host "Unsupported media type: $mediaType" 72 | return 73 | } 74 | 75 | Write-Host "Extracted Title: $title" 76 | Write-Host "Extracted Year: $year" 77 | 78 | if (-not $plexToken -or -not $plexHost) { 79 | Write-Host "Plex host or token not set in environment variables" 80 | return 81 | } 82 | 83 | $searchUrl = "$plexHost/library/sections/$sectionId/all?type=$mediaTypeForSearch&title=" + [System.Uri]::EscapeDataString($title) + "&year=$year&X-Plex-Token=$plexToken" 84 | Write-Host "Search URL: $searchUrl" 85 | try { 86 | $mediaItems = Invoke-RestMethod -Uri $searchUrl -Method Get -ContentType "application/xml" 87 | } catch { 88 | Write-Host "Error contacting Plex server: $_" 89 | return 90 | } 91 | 92 | try { 93 | if ($mediaType -eq "movie") { 94 | $mediaItem = $mediaItems.MediaContainer.Video | Where-Object { $_.title -eq $title -and $_.year -eq $year } 95 | } elseif ($mediaType -eq "tv") { 96 | $mediaItem = $mediaItems.MediaContainer.Directory | Where-Object { $_.title -eq $title -and $_.year -eq $year } 97 | } 98 | } catch { 99 | Write-Host "Error parsing Plex server response: $_" 100 | return 101 | } 102 | 103 | if ($null -eq $mediaItem) { 104 | Write-Host "Media item not found after filtering" 105 | return 106 | } 107 | 108 | $ratingKey = $mediaItem.ratingKey 109 | Write-Host "Extracted Rating Key: $ratingKey" 110 | 111 | $metadataUrl = "$plexHost/library/metadata/$ratingKey" + "?X-Plex-Token=$plexToken" 112 | Write-Host "Metadata URL: $metadataUrl" 113 | try { 114 | $metadata = Invoke-RestMethod -Uri $metadataUrl -Method Get -ContentType "application/xml" 115 | } catch { 116 | Write-Host "Error retrieving metadata: $_" 117 | return 118 | } 119 | 120 | $currentLabels = @() 121 | if ($metadata.MediaContainer.Video.Label) { 122 | if ($metadata.MediaContainer.Video.Label -is [System.Array]) { 123 | $currentLabels = $metadata.MediaContainer.Video.Label | ForEach-Object { $_.tag } 124 | } else { 125 | $currentLabels = @($metadata.MediaContainer.Video.Label.tag) 126 | } 127 | } elseif ($metadata.MediaContainer.Directory.Label) { 128 | if ($metadata.MediaContainer.Directory.Label -is [System.Array]) { 129 | $currentLabels = $metadata.MediaContainer.Directory.Label | ForEach-Object { $_.tag } 130 | } else { 131 | $currentLabels = @($metadata.MediaContainer.Directory.Label.tag) 132 | } 133 | } 134 | 135 | if (-not ($currentLabels -contains $label)) { 136 | $currentLabels += $label 137 | } 138 | 139 | $encodedLabels = $currentLabels | ForEach-Object { "label[$($currentLabels.IndexOf($_))].tag.tag=" + [System.Uri]::EscapeDataString($_) } 140 | $encodedLabelsString = $encodedLabels -join "&" 141 | $updateUrl = "$plexHost/library/metadata/$ratingKey" + "?X-Plex-Token=$plexToken&$encodedLabelsString&label.locked=1" 142 | 143 | try { 144 | $responseResult = Invoke-RestMethod -Uri $updateUrl -Method Put 145 | Write-Host "Label added to media item: $($currentLabels -join ', ')" 146 | 147 | $message = "$subject is now available in your library" 148 | Post-OverseerrComment -issueId $payload.issue.issue_id -message $message -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 149 | Resolve-OverseerrIssue -issueId $payload.issue.issue_id -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 150 | } catch { 151 | Write-Host "Error adding label to media item: $_" 152 | Write-Host "Request URL: $updateUrl" 153 | } 154 | } -------------------------------------------------------------------------------- /functions/Handle-SonarrEpisodeFileAdded.ps1: -------------------------------------------------------------------------------- 1 | function Handle-SonarrEpisodeFileAdded { 2 | param ([psobject]$payload) 3 | 4 | $title = $payload.series.title 5 | $tmdbId = $payload.series.tmdbId 6 | $tvdbId = $payload.series.tvdbId 7 | $seasonNumber = $payload.episodes[0].seasonNumber 8 | $episodeNumber = $payload.episodes[0].episodeNumber 9 | 10 | Log-Message -Type "INF" -Message "New episode added: $title S$seasonNumber E$episodeNumber" 11 | 12 | if (-not $plexHost -or -not $plexToken) { 13 | Log-Message -Type "ERR" -Message "Plex host or token not set. Cannot proceed." 14 | return 15 | } 16 | 17 | $sectionIds = $seriesSectionIds 18 | $show = $null 19 | 20 | # Optional delay to allow Plex metadata to be fully available 21 | $delay = $env:SONARR_TRACK_DELAY_SECONDS 22 | if (-not $delay) { $delay = 10 } # default to 10s if not set 23 | Log-Message -Type "INF" -Message "Waiting $delay seconds before applying track preferences..." 24 | Start-Sleep -Seconds $delay 25 | 26 | # Search for the show 27 | foreach ($sectionId in $sectionIds) { 28 | $searchUrl = "$plexHost/library/sections/$sectionId/all?type=2&title=" + [System.Uri]::EscapeDataString($title) + "&X-Plex-Token=$plexToken" 29 | try { 30 | $result = Invoke-RestMethod -Uri $searchUrl -Method Get -ContentType "application/xml" 31 | $matchedShow = $result.MediaContainer.Directory | Where-Object { $_.title -eq $title } 32 | if ($matchedShow) { 33 | $show = $matchedShow 34 | break 35 | } 36 | } catch { 37 | Log-Message -Type "ERR" -Message "Error searching Plex section ${sectionId}: $_" 38 | } 39 | } 40 | 41 | if (-not $show) { 42 | Log-Message -Type "ERR" -Message "Could not locate series '$title' in Plex." 43 | return 44 | } 45 | 46 | $showKey = $show.ratingKey 47 | 48 | # Fetch seasons 49 | $seasonsUrl = "$plexHost/library/metadata/$showKey/children"+"?X-Plex-Token=$plexToken" 50 | try { 51 | $seasonsMetadata = Invoke-RestMethod -Uri $seasonsUrl -Method Get -ContentType "application/xml" 52 | } catch { 53 | Log-Message -Type "ERR" -Message "Error retrieving seasons for '$title': $_" 54 | return 55 | } 56 | 57 | # Try to find the correct season by index 58 | $season = $seasonsMetadata.MediaContainer.Directory | Where-Object { $_.index -eq $seasonNumber } 59 | 60 | if (-not $season) { 61 | Log-Message -Type "ERR" -Message "Season $seasonNumber not found for '$title'" 62 | return 63 | } 64 | 65 | # Fetch episodes in the season 66 | $seasonKey = $season.ratingKey 67 | $episodesUrl = "$plexHost/library/metadata/$seasonKey/children"+"?X-Plex-Token=$plexToken" 68 | try { 69 | $episodesMetadata = Invoke-RestMethod -Uri $episodesUrl -Method Get -ContentType "application/xml" 70 | } catch { 71 | Log-Message -Type "ERR" -Message "Error retrieving episodes for season ${seasonNumber}: $_" 72 | return 73 | } 74 | 75 | # Find episode by index 76 | $episode = $episodesMetadata.MediaContainer.Video | Where-Object { $_.index -eq $episodeNumber } 77 | 78 | if (-not $episode) { 79 | Log-Message -Type "ERR" -Message "Episode S$seasonNumber E$episodeNumber not found in Plex for '$title'" 80 | return 81 | } 82 | 83 | $ratingKey = $episode.ratingKey 84 | Log-Message -Type "SUC" -Message "Found Plex episode ratingKey: $ratingKey" 85 | 86 | 87 | 88 | if ($enableAudioPref) { 89 | Set-AudioTrack -ratingKey $ratingKey 90 | Set-SubtitleTrack -ratingKey $ratingKey 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /functions/Handle-SubtitlesIssue.ps1: -------------------------------------------------------------------------------- 1 | function Handle-SubtitlesIssue { 2 | param ([psobject]$payload) 3 | 4 | 5 | # Fetch user details from Overseerr API 6 | $reportedByPlexUsername = $payload.issue.reportedBy_username 7 | $userEndpoint = "$overseerrUrl/user?take=30&skip=0&sort=created" 8 | $headers = @{ 9 | "accept" = "application/json" 10 | "X-Api-Key" = $overseerrApiKey 11 | } 12 | 13 | $locale = "en" # Default to English if the locale is not found 14 | 15 | try { 16 | # Get the list of users 17 | $response = Invoke-RestMethod -Uri $userEndpoint -Headers $headers -Method Get 18 | $users = $response.results 19 | 20 | # Find the user with the matching Plex username 21 | $user = $users | Where-Object { $_.plexUsername -eq $reportedByPlexUsername } 22 | if ($user) { 23 | Log-Message -Type "INF" -Message "User found: $($user.plexUsername)" 24 | $userId = $user.id 25 | $userDetailsApiUrl = "$overseerrUrl/user/$userId" 26 | 27 | try { 28 | $userDetailsResponse = Invoke-RestMethod -Uri $userDetailsApiUrl -Headers $headers -Method Get 29 | if ($userDetailsResponse.settings -and $userDetailsResponse.settings.locale) { 30 | $locale = $userDetailsResponse.settings.locale 31 | Log-Message -Type "INF" -Message "User's locale: $locale" 32 | } else { 33 | Log-Message -Type "WRN" -Message "Locale not found in user's settings, using default locale: en" 34 | } 35 | } catch { 36 | Log-Message -Type "ERR" -Message "Failed to fetch detailed user settings: $_" 37 | Log-Message -Type "INF" -Message "Using default locale." 38 | } 39 | } else { 40 | Log-Message -Type "WRN" -Message "User not found, using default locale." 41 | } 42 | } catch { 43 | Log-Message -Type "ERR" -Message "Failed to fetch user details from Overseerr: $_" 44 | Log-Message -Type "INF" -Message "Using default locale." 45 | } 46 | 47 | $is4K = $payload.message -match "(?i)4K" 48 | $isHI = $payload.message -match "(?i)hi" 49 | $containsSyncKeyword = Contains-SyncKeyword -issueMessage $payload.message -syncKeywords $syncKeywords 50 | $containsAdjustBy = $payload.message -match "(?i)adjust by" 51 | $containsOffset = $payload.message -match "(?i)offset" 52 | $containsTranslate = $payload.message -match "(?i)translate" 53 | 54 | 55 | if (-not $containsSyncKeyword -and -not $containsAdjustBy -and -not $containsOffset -and -not $containsTranslate) { 56 | Log-Message -Type "INF" -Message "Issue message does not contain sync, adjust by, offset, or translate keywords, skipping." 57 | return 58 | } 59 | 60 | $offset = Extract-Offset -message $payload.message 61 | $shiftOffset = ShiftOffset -message $payload.message 62 | $bazarrApiKey = if ($is4K) { $bazarr4kApiKey } else { $bazarrApiKey } 63 | $bazarrUrl = if ($is4K) { $bazarr4kUrl } else { $bazarrUrl } 64 | $radarrApiKey = if ($is4K) { $radarr4kApiKey } else { $radarrApiKey } 65 | $radarrUrl = if ($is4K) { $radarr4kUrl } else { $radarrUrl } 66 | $sonarrApiKey = if ($is4K) { $sonarr4kApiKey } else { $sonarrApiKey } 67 | $sonarrUrl = if ($is4K) { $sonarr4kUrl } else { $sonarrUrl } 68 | 69 | Log-Message -Type "INF" -Message "Using bazarrUrl: $bazarrUrl" 70 | 71 | # Handle movie subtitles 72 | if ($payload.media.media_type -eq "movie") { 73 | $tmdbId = $payload.media.tmdbId 74 | Log-Message -Type "INF" -Message "Fetching movie details from Radarr for tmdbId: $tmdbId" 75 | 76 | try { 77 | $radarrMovieDetails = Get-RadarrMovieDetails -tmdbId $tmdbId -radarrApiKey $radarrApiKey -radarrUrl $radarrUrl 78 | } catch { 79 | WritLog-Message -Type "ERR" -Messagee-Host (Translate-Message -key "MovieDetailsNotFound" -language $locale) 80 | return 81 | } 82 | 83 | if ($radarrMovieDetails) { 84 | $movieId = $radarrMovieDetails.id 85 | $radarrId = $movieId 86 | Log-Message -Type "INF" -Message "Movie ID: $movieId" 87 | 88 | if ($containsTranslate) { 89 | if ($payload.message -match "translate (\w{2}) to (\w{2})") { 90 | $sourceLang = $matches[1] 91 | $targetLang = $matches[2] 92 | Log-Message -Type "INF" -Message "Source language: $sourceLang, Target language: $targetLang" 93 | 94 | $sourceLanguageName = Map-LanguageCode -languageCode $sourceLang -languageMap $languageMap 95 | Log-Message -Type "INF" -Message "Mapped Source Language Name: $sourceLanguageName" 96 | $targetLanguageName = Map-LanguageCode -languageCode $targetLang -languageMap $languageMap 97 | Log-Message -Type "INF" -Message "Mapped Target Language Name: $targetLanguageName" 98 | 99 | $newSubtitlePath = Get-BazarrMovieSubtitlePath -radarrId $radarrId -languageName $sourceLanguageName -hearingImpaired $isHI -bazarrApiKey $bazarrApiKey -bazarrUrl $bazarrUrl 100 | Log-Message -Type "INF" -Message "Subtitle Path: $newSubtitlePath" 101 | 102 | if ($newSubtitlePath) { 103 | $encodedSubtitlePath = [System.Web.HttpUtility]::UrlEncode($newSubtitlePath) 104 | $targetLanguageCode = Get-BazarrLanguageCode -languageName $targetLanguageName -bazarrUrl $bazarrUrl -bazarrApiKey $bazarrApiKey 105 | if ($targetLanguageCode) { 106 | if ($useGPT) { 107 | Log-Message -Type "INF" -Message "Translating with GPT" 108 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "GptTranslateStart" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 109 | $gptSubtitlePath = Get-GPTTranslationChunked -text (Get-SubtitleText -subtitlePath $newSubtitlePath) -sourceLang $sourceLang -targetLang $targetLang 110 | Set-SubtitleText -subtitlePath $newSubtitlePath -text $gptSubtitlePath -targetLang $targetLang 111 | Log-Message -Type "SUC" -Message "GPT Translation completed" 112 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "TranslationFinished" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 113 | Resolve-OverseerrIssue -issueId $payload.issue.issue_id -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 114 | } else { 115 | $bazarrUrlWithParams = "$bazarrUrl/subtitles?action=translate&language=$targetLanguageCode&path=$encodedSubtitlePath&type=movie&id=$movieId&apikey=$bazarrApiKey" 116 | Log-Message -Type "INF" -Message "Sending translation request to Bazarr with URL: $bazarrUrlWithParams" 117 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "TranslationStarted" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 118 | try { 119 | $bazarrResponse = Invoke-RestMethod -Uri $bazarrUrlWithParams -Method Patch 120 | Log-Message -Type "INF" -Message "Bazarr response: Translated" 121 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "TranslationFinished" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 122 | Resolve-OverseerrIssue -issueId $payload.issue.issue_id -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 123 | } catch { 124 | Log-Message -Type "ERR" -Message "Failed to send translation request to Bazarr: $_" 125 | } 126 | } 127 | } else { 128 | Log-Message -Type "ERR" -Message "Failed to get Bazarr language code for $targetLanguageName" 129 | } 130 | } else { 131 | Log-Message -Type "ERR" -Message (Translate-Message -key "SubtitlesMissing" -language $locale) 132 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "SubtitlesMissing" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 133 | Resolve-OverseerrIssue -issueId $payload.issue.issue_id -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 134 | } 135 | } else { 136 | Log-Message -Type "ERR" -Message (Translate-Message -key "FailedToParseLanguages" -language $locale) 137 | } 138 | return 139 | } 140 | 141 | # Syncing logic for movies 142 | $languageName = Map-LanguageCode -languageCode $payload.message.Split()[0] -languageMap $languageMap 143 | Log-Message -Type "INF" -Message "Mapped Language Name: $languageName" 144 | 145 | $newSubtitlePath = Get-BazarrMovieSubtitlePath -radarrId $radarrId -languageName $languageName -hearingImpaired $isHI -bazarrApiKey $bazarrApiKey -bazarrUrl $bazarrUrl 146 | Log-Message -Type "INF" -Message "Subtitle Path: $newSubtitlePath" 147 | 148 | if ($newSubtitlePath) { 149 | $languageCode = Extract-LanguageCodeFromPath -subtitlePath $newSubtitlePath 150 | Log-Message -Type "INF" -Message "Extracted Language Code: $languageCode" 151 | 152 | $encodedSubtitlePath = [System.Web.HttpUtility]::UrlEncode($newSubtitlePath) 153 | if ($containsAdjustBy -and $shiftOffset -ne $null) { 154 | $shiftOffsetEncoded = [System.Web.HttpUtility]::UrlEncode($shiftOffset) 155 | $bazarrUrlWithParams = "$bazarrUrl/subtitles?action=shift_offset($shiftOffsetEncoded)&language=$languageCode&path=$encodedSubtitlePath&type=movie&id=$movieId&apikey=$bazarrApiKey" 156 | } elseif ($containsOffset -and $offset -ne $null) { 157 | $bazarrUrlWithParams = "$bazarrUrl/subtitles?action=sync&language=$languageCode&path=$encodedSubtitlePath&type=movie&id=$movieId&reference=(a%3A0)&gss=true&max_offset_seconds=$offset&apikey=$bazarrApiKey" 158 | } else { 159 | $bazarrUrlWithParams = "$bazarrUrl/subtitles?action=sync&language=$languageCode&path=$encodedSubtitlePath&type=movie&id=$movieId&reference=(a%3A0)&gss=true&apikey=$bazarrApiKey" 160 | } 161 | Log-Message -Type "INF" -Message "Sending PATCH request to Bazarr with URL: $bazarrUrlWithParams" 162 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "SyncStarted" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 163 | 164 | try { 165 | $bazarrResponse = Invoke-RestMethod -Uri $bazarrUrlWithParams -Method Patch 166 | Log-Message -Type "SUC" -Message "Bazarr response: Synced" 167 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "SyncFinished" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 168 | Resolve-OverseerrIssue -issueId $payload.issue.issue_id -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 169 | } catch { 170 | Log-Message -Type "ERR" -Message "Failed to send PATCH request to Bazarr: $_" 171 | } 172 | } else { 173 | Log-Message -Type "ERR" -Message (Translate-Message -key "SubtitlesMissing" -language $locale) 174 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "SubtitlesMissing" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 175 | Resolve-OverseerrIssue -issueId $payload.issue.issue_id -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 176 | } 177 | } else { 178 | Log-Message -Type "ERR" -Message (Translate-Message -key "MovieDetailsNotFound" -language $locale) 179 | } 180 | 181 | # Handle TV subtitles 182 | } elseif ($payload.media.media_type -eq "tv") { 183 | $tvdbId = $payload.media.tvdbId 184 | $affectedSeason = $payload.extra | Where-Object { $_.name -eq "Affected Season" } | Select-Object -ExpandProperty value 185 | $affectedEpisode = $payload.extra | Where-Object { $_.name -eq "Affected Episode" } | Select-Object -ExpandProperty value 186 | Log-Message -Type "INF" -Message "Fetching seriesId from Sonarr for tvdbId: $tvdbId" 187 | 188 | $seriesId = Get-SonarrSeriesId -tvdbId $tvdbId -sonarrApiKey $sonarrApiKey -sonarrUrl $sonarrUrl 189 | if ($seriesId) { 190 | Log-Message -Type "INF" -Message "Series ID: $seriesId" 191 | 192 | if ($affectedEpisode) { 193 | Log-Message -Type "INF" -Message "Fetching episode details from Sonarr for seriesId: $seriesId, season: $affectedSeason, episode: $affectedEpisode" 194 | $episodeDetails = Get-SonarrEpisodeDetails -seriesId $seriesId -seasonNumber ([int]$affectedSeason) -episodeNumber ([int]$affectedEpisode) -sonarrApiKey $sonarrApiKey -sonarrUrl $sonarrUrl 195 | if ($episodeDetails) { 196 | $episodeId = $episodeDetails.id 197 | $episodeNumber = $episodeDetails.episodeNumber 198 | Log-Message -Type "INF" -Message "Episode ID: $episodeId, Episode Number: $episodeNumber" 199 | 200 | if ($containsTranslate) { 201 | if ($payload.message -match "translate (\w{2}) to (\w{2})") { 202 | $sourceLang = $matches[1] 203 | $targetLang = $matches[2] 204 | Log-Message -Type "INF" -Message "Source language: $sourceLang, Target language: $targetLang" 205 | 206 | $sourceLanguageName = Map-LanguageCode -languageCode $sourceLang -languageMap $languageMap 207 | Log-Message -Type "INF" -Message "Mapped Source Language Name: $sourceLanguageName" 208 | $targetLanguageName = Map-LanguageCode -languageCode $targetLang -languageMap $languageMap 209 | Log-Message -Type "INF" -Message "Mapped Target Language Name: $targetLanguageName" 210 | 211 | $newSubtitlePath = Get-BazarrEpisodeSubtitlePath -seriesId $seriesId -episodeId $episodeId -languageName $sourceLanguageName -hearingImpaired $isHI -bazarrApiKey $bazarrApiKey -bazarrUrl $bazarrUrl 212 | Log-Message -Type "INF" -Message "Subtitle Path: $newSubtitlePath" 213 | 214 | if ($newSubtitlePath) { 215 | $encodedSubtitlePath = [System.Web.HttpUtility]::UrlEncode($newSubtitlePath) 216 | $targetLanguageCode = Get-BazarrLanguageCode -languageName $targetLanguageName -bazarrUrl $bazarrUrl -bazarrApiKey $bazarrApiKey 217 | if ($targetLanguageCode) { 218 | if ($useGPT) { 219 | Log-Message -Type "INF" -Message "Translating with GPT" 220 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "GptTranslateStart" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 221 | $gptSubtitlePath = Get-GPTTranslationChunked -text (Get-SubtitleText -subtitlePath $newSubtitlePath) -sourceLang $sourceLang -targetLang $targetLang 222 | Set-SubtitleText -subtitlePath $newSubtitlePath -text $gptSubtitlePath -targetLang $targetLang 223 | Log-Message -Type "SUC" -Message "GPT Translation completed" 224 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "TranslationFinished" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 225 | Resolve-OverseerrIssue -issueId $payload.issue.issue_id -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 226 | } else { 227 | $bazarrUrlWithParams = "$bazarrUrl/subtitles?action=translate&language=$targetLanguageCode&path=$encodedSubtitlePath&type=episode&id=$episodeId&apikey=$bazarrApiKey" 228 | Log-Message -Type "INF" -Message "Sending translation request to Bazarr with URL: $bazarrUrlWithParams" 229 | try { 230 | $bazarrResponse = Invoke-RestMethod -Uri $bazarrUrlWithParams -Method Patch 231 | Log-Message -Type "SUC" -Message "Bazarr response: Translated" 232 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "TranslationFinished" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 233 | Resolve-OverseerrIssue -issueId $payload.issue.issue_id -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 234 | } catch { 235 | Log-Message -Type "ERR" -Message "Failed to send translation request to Bazarr: $_" 236 | } 237 | } 238 | } else { 239 | Log-Message -Type "ERR" -Message "Failed to get Bazarr language code for $targetLanguageName" 240 | } 241 | } else { 242 | Log-Message -Type "ERR" -Message (Translate-Message -key "SubtitlesMissing" -language $locale) 243 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "SubtitlesMissing" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 244 | Resolve-OverseerrIssue -issueId $payload.issue.issue_id -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 245 | } 246 | } else { 247 | Log-Message -Type "ERR" -Message (Translate-Message -key "FailedToParseLanguages" -language $locale) 248 | } 249 | return 250 | } 251 | 252 | # Syncing logic for single episode 253 | $languageName = Map-LanguageCode -languageCode $payload.message.Split()[0] -languageMap $languageMap 254 | Log-Message -Type "INF" -Message "Mapped Language Name: $languageName" 255 | 256 | $newSubtitlePath = Get-BazarrEpisodeSubtitlePath -seriesId $seriesId -episodeId $episodeId -languageName $languageName -hearingImpaired $isHI -bazarrApiKey $bazarrApiKey -bazarrUrl $bazarrUrl 257 | Log-Message -Type "INF" -Message "Subtitle Path: $newSubtitlePath" 258 | 259 | if ($newSubtitlePath) { 260 | $languageCode = Extract-LanguageCodeFromPath -subtitlePath $newSubtitlePath 261 | Log-Message -Type "INF" -Message "Extracted Language Code: $languageCode" 262 | 263 | $encodedSubtitlePath = [System.Web.HttpUtility]::UrlEncode($newSubtitlePath) 264 | if ($containsAdjustBy -and $shiftOffset -ne $null) { 265 | $shiftOffsetEncoded = [System.Web.HttpUtility]::UrlEncode($shiftOffset) 266 | $bazarrUrlWithParams = "$bazarrUrl/subtitles?action=shift_offset($shiftOffsetEncoded)&language=$languageCode&path=$encodedSubtitlePath&type=episode&id=$episodeId&apikey=$bazarrApiKey" 267 | } elseif ($containsOffset -and $offset -ne $null) { 268 | $bazarrUrlWithParams = "$bazarrUrl/subtitles?action=sync&language=$languageCode&path=$encodedSubtitlePath&type=episode&id=$episodeId&reference=(a%3A0)&gss=true&max_offset_seconds=$offset&apikey=$bazarrApiKey" 269 | } else { 270 | $bazarrUrlWithParams = "$bazarrUrl/subtitles?action=sync&language=$languageCode&path=$encodedSubtitlePath&type=episode&id=$episodeId&reference=(a%3A0)&gss=true&apikey=$bazarrApiKey" 271 | } 272 | Log-Message -Type "INF" -Message "Sending PATCH request to Bazarr with URL: $bazarrUrlWithParams" 273 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "SyncStarted" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 274 | 275 | try { 276 | $bazarrResponse = Invoke-RestMethod -Uri $bazarrUrlWithParams -Method Patch 277 | Log-Message -Type "SUC" -Message "Bazarr response: Synced" 278 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "SyncFinished" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 279 | Resolve-OverseerrIssue -issueId $payload.issue.issue_id -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 280 | } catch { 281 | Log-Message -Type "ERR" -Message "Failed to send PATCH request to Bazarr: $_" 282 | } 283 | } else { 284 | Log-Message -Type "ERR" -Message (Translate-Message -key "SubtitlesMissing" -language $locale) 285 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "SubtitlesMissing" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 286 | Resolve-OverseerrIssue -issueId $payload.issue.issue_id -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 287 | } 288 | } else { 289 | Log-Message -Type "ERR" -Message "Episode details not found in Sonarr" 290 | } 291 | 292 | } else { 293 | # Syncing logic for multiple episodes 294 | Log-Message -Type "INF" -Message "Affected Episode missing, fetching all episodes for season: $affectedSeason" 295 | $episodes = Get-SonarrEpisodesBySeason -seriesId $seriesId -seasonNumber ([int]$affectedSeason) -sonarrApiKey $sonarrApiKey -sonarrUrl $sonarrUrl 296 | if ($episodes) { 297 | if ($containsTranslate) { 298 | if ($payload.message -match "translate (\w{2}) to (\w{2})") { 299 | $sourceLang = $matches[1] 300 | $targetLang = $matches[2] 301 | Log-Message -Type "INF" -Message "Source language: $sourceLang, Target language: $targetLang" 302 | 303 | $sourceLanguageName = Map-LanguageCode -languageCode $sourceLang -languageMap $languageMap 304 | Log-Message -Type "INF" -Message "Mapped Source Language Name: $sourceLanguageName" 305 | $targetLanguageName = Map-LanguageCode -languageCode $targetLang -languageMap $languageMap 306 | Log-Message -Type "INF" -Message "Mapped Target Language Name: $targetLanguageName" 307 | 308 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "TranslationStarted" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 309 | 310 | foreach ($episode in $episodes) { 311 | $episodeId = $episode.id 312 | $episodeNumber = $episode.episodeNumber 313 | Log-Message -Type "INF" -Message "Processing episode ID: $episodeId, Episode Number: $episodeNumber" 314 | 315 | $newSubtitlePath = Get-BazarrEpisodeSubtitlePath -seriesId $seriesId -episodeId $episodeId -languageName $sourceLanguageName -hearingImpaired $isHI -bazarrApiKey $bazarrApiKey -bazarrUrl $bazarrUrl 316 | Log-Message -Type "INF" -Message "Subtitle Path: $newSubtitlePath" 317 | 318 | if ($newSubtitlePath) { 319 | $encodedSubtitlePath = [System.Web.HttpUtility]::UrlEncode($newSubtitlePath) 320 | $targetLanguageCode = Get-BazarrLanguageCode -languageName $targetLanguageName -bazarrUrl $bazarrUrl -bazarrApiKey $bazarrApiKey 321 | if ($targetLanguageCode) { 322 | if ($useGPT) { 323 | Log-Message -Type "INF" -Message "Translating with GPT" 324 | $gptSubtitlePath = Get-GPTTranslationChunked -text (Get-SubtitleText -subtitlePath $newSubtitlePath) -sourceLang $sourceLang -targetLang $targetLang 325 | Set-SubtitleText -subtitlePath $newSubtitlePath -text $gptSubtitlePath -targetLang $targetLang 326 | Log-Message -Type "SUC" -Message "GPT Translation completed" 327 | } else { 328 | $bazarrUrlWithParams = "$bazarrUrl/subtitles?action=translate&language=$targetLanguageCode&path=$encodedSubtitlePath&type=episode&id=$episodeId&apikey=$bazarrApiKey" 329 | Log-Message -Type "INF" -Message "Sending translation request to Bazarr with URL: $bazarrUrlWithParams" 330 | try { 331 | $bazarrResponse = Invoke-RestMethod -Uri $bazarrUrlWithParams -Method Patch 332 | Log-Message -Type "SUC" -Message "Bazarr response: Translated" 333 | } catch { 334 | Log-Message -Type "ERR" -Message "Failed to send translation request to Bazarr: $_" 335 | } 336 | } 337 | } else { 338 | Log-Message -Type "ERR" -Message "Failed to get Bazarr language code for $targetLanguageName" 339 | } 340 | } else { 341 | Log-Message -Type "INF" -Message (Translate-Message -key "SubtitlesMissing" -language $locale) 342 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "SubtitlesMissing" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 343 | Resolve-OverseerrIssue -issueId $payload.issue.issue_id -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 344 | } 345 | } 346 | 347 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "TranslationFinished" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 348 | Resolve-OverseerrIssue -issueId $payload.issue.issue_id -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 349 | } else { 350 | Log-Message -Type "ERR" -Message (Translate-Message -key "FailedToParseLanguages" -language $locale) 351 | } 352 | return 353 | } 354 | 355 | $languageName = Map-LanguageCode -languageCode $payload.message.Split()[0] -languageMap $languageMap 356 | Log-Message -Type "INF" -Message "Mapped Language Name: $languageName" 357 | 358 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "SyncStarted" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 359 | 360 | $allSubtitlesSynced = $true 361 | $failedEpisodes = @() 362 | 363 | foreach ($episode in $episodes) { 364 | $episodeId = $episode.id 365 | $episodeNumber = $episode.episodeNumber 366 | Log-Message -Type "INF" -Message "Processing episode ID: $episodeId, Episode Number: $episodeNumber" 367 | 368 | $newSubtitlePath = Get-BazarrEpisodeSubtitlePath -seriesId $seriesId -episodeId $episodeId -languageName $languageName -hearingImpaired $isHI -bazarrApiKey $bazarrApiKey -bazarrUrl $bazarrUrl 369 | Log-Message -Type "INF" -Message "Subtitle Path: $newSubtitlePath" 370 | 371 | if ($newSubtitlePath) { 372 | $languageCode = Extract-LanguageCodeFromPath -subtitlePath $newSubtitlePath 373 | Log-Message -Type "INF" -Message "Extracted Language Code: $languageCode" 374 | 375 | $encodedSubtitlePath = [System.Web.HttpUtility]::UrlEncode($newSubtitlePath) 376 | if ($containsAdjustBy -and $shiftOffset -ne $null) { 377 | $shiftOffsetEncoded = [System.Web.HttpUtility]::UrlEncode($shiftOffset) 378 | $bazarrUrlWithParams = "$bazarrUrl/subtitles?action=shift_offset($shiftOffsetEncoded)&language=$languageCode&path=$encodedSubtitlePath&type=episode&id=$episodeId&apikey=$bazarrApiKey" 379 | } elseif ($containsOffset -and $offset -ne $null) { 380 | $bazarrUrlWithParams = "$bazarrUrl/subtitles?action=sync&language=$languageCode&path=$encodedSubtitlePath&type=episode&id=$episodeId&reference=(a%3A0)&gss=true&max_offset_seconds=$offset&apikey=$bazarrApiKey" 381 | } else { 382 | $bazarrUrlWithParams = "$bazarrUrl/subtitles?action=sync&language=$languageCode&path=$encodedSubtitlePath&type=episode&id=$episodeId&reference=(a%3A0)&gss=true&apikey=$bazarrApiKey" 383 | } 384 | Log-Message -Type "INF" -Message "Sending PATCH request to Bazarr with URL: $bazarrUrlWithParams" 385 | 386 | try { 387 | $bazarrResponse = Invoke-RestMethod -Uri $bazarrUrlWithParams -Method Patch 388 | Log-Message -Type "SUC" -Message "Bazarr response: Synced" 389 | } catch { 390 | Log-Message -Type "ERR" -Message "Failed to send PATCH request to Bazarr: $_" 391 | $allSubtitlesSynced = $false 392 | $failedEpisodes += $episodeNumber 393 | } 394 | } else { 395 | Write-Host "Subtitle path not found in Bazarr for episode ID: $episodeId" 396 | $allSubtitlesSynced = $false 397 | $failedEpisodes += $episodeNumber 398 | } 399 | } 400 | 401 | if ($allSubtitlesSynced) { 402 | Post-OverseerrComment -issueId $payload.issue.issue_id -message (Translate-Message -key "SyncFinished" -language $locale) -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 403 | Resolve-OverseerrIssue -issueId $payload.issue.issue_id -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 404 | } else { 405 | $failedEpisodesStr = ($failedEpisodes | ForEach-Object { "$_" }) -join ", " 406 | Post-OverseerrComment -issueId $payload.issue.issue_id -message "$(Translate-Message -key "SubtitlesPartiallySynced" -language $locale) $failedEpisodesStr" -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 407 | Resolve-OverseerrIssue -issueId $payload.issue.issue_id -overseerrApiKey $overseerrApiKey -overseerrUrl $overseerrUrl 408 | } 409 | } else { 410 | Log-Message -Type "ERR" -Message "No episodes found for season: $affectedSeason" 411 | } 412 | } 413 | } else { 414 | Log-Message -Type "ERR" -Message "Series ID not found in Sonarr" 415 | } 416 | } else { 417 | Log-Message -Type "ERR" -Message "Unsupported media type: $($payload.media.media_type)" 418 | } 419 | } 420 | -------------------------------------------------------------------------------- /functions/Handle-Webhook.ps1: -------------------------------------------------------------------------------- 1 | function Handle-Webhook { 2 | param ([string]$jsonPayload) 3 | 4 | $payload = $jsonPayload | ConvertFrom-Json -AsHashtable 5 | 6 | # Retrieve ADD_LABEL_KEYWORDS env var 7 | $addLabelKeywords = $env:ADD_LABEL_KEYWORDS | ConvertFrom-Json 8 | 9 | # Plex: Handle new episode 10 | if ( 11 | $payload.ContainsKey('event') -and 12 | $payload["event"] -eq "library.new" -and 13 | $payload["Metadata"]["librarySectionType"] -eq "show" -and 14 | $payload["Metadata"]["type"] -eq "episode" 15 | ) { 16 | $ratingKey = $payload["Metadata"]["ratingKey"] 17 | $title = $payload["Metadata"]["grandparentTitle"] 18 | $season = $payload["Metadata"]["parentIndex"] 19 | $episode = $payload["Metadata"]["index"] 20 | $episodeTitle = $payload["Metadata"]["title"] 21 | 22 | Log-Message -Type "INF" -Message "📺 New episode added: $title - S$("{0:D2}" -f $season)E$("{0:D2}" -f $episode) \"$episodeTitle\"" 23 | 24 | if ($enableAudioPref) { 25 | Set-AudioTrack -RatingKey $ratingKey 26 | Set-SubtitleTrack -RatingKey $ratingKey 27 | } 28 | 29 | return 30 | } 31 | 32 | # Plex: Handle new show (fetch recently added children episodes) 33 | if ( 34 | $payload.ContainsKey('event') -and 35 | $payload["event"] -eq "library.new" -and 36 | $payload["Metadata"]["librarySectionType"] -eq "show" -and 37 | $payload["Metadata"]["type"] -eq "show" 38 | ) { 39 | $showTitle = $payload["Metadata"]["title"] 40 | $showRatingKey = $payload["Metadata"]["ratingKey"] 41 | Log-Message -Type "INF" -Message "📚 New show added: $showTitle (RatingKey: $showRatingKey)" 42 | 43 | if ($enableAudioPref) { 44 | try { 45 | $episodesUrl = "$plexHost/library/metadata/$showRatingKey/allLeaves?X-Plex-Token=$plexToken" 46 | $episodesMetadata = Invoke-RestMethod -Uri $episodesUrl -Method Get -ContentType "application/xml" 47 | 48 | $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() 49 | $thresholdSeconds = 300 # Only consider episodes added in the last 5 minutes 50 | 51 | $recentEpisodes = @($episodesMetadata.MediaContainer.Video | Where-Object { 52 | ($now - $_.addedAt) -lt $thresholdSeconds 53 | }) 54 | 55 | if ($recentEpisodes.Count -eq 0) { 56 | Log-Message -Type "INF" -Message "No newly added episodes found in show. Skipping." 57 | return 58 | } 59 | 60 | foreach ($episode in $recentEpisodes) { 61 | $episodeTitle = $episode.title 62 | $season = $episode.parentIndex 63 | $epNum = $episode.index 64 | $epKey = $episode.ratingKey 65 | 66 | Log-Message -Type "INF" -Message "🎬 Processing: $showTitle S$("{0:D2}" -f $season)E$("{0:D2}" -f $epNum) - $episodeTitle" 67 | 68 | Set-AudioTrack -RatingKey $epKey 69 | Set-SubtitleTrack -RatingKey $epKey 70 | } 71 | 72 | } catch { 73 | Log-Message -Type "ERR" -Message "Failed to retrieve or process episodes for $showTitle. Error: $_" 74 | } 75 | } 76 | 77 | return 78 | } 79 | 80 | 81 | # Overseerr MEDIA_AVAILABLE 82 | if ($payload["notification_type"] -eq "MEDIA_AVAILABLE") { 83 | Handle-MediaAvailable -payload $payload 84 | 85 | if ($enableKometa) { 86 | Trigger-Kometa 87 | } 88 | 89 | } elseif ($payload.ContainsKey("issue") -and $payload["issue"]["issue_type"] -eq "SUBTITLES") { 90 | Handle-SubtitlesIssue -payload $payload 91 | 92 | } elseif ($payload.ContainsKey("issue") -and $payload["issue"]["issue_type"] -eq "OTHER") { 93 | $message = $payload["message"].ToLower().Trim() 94 | $matchFound = $false 95 | 96 | foreach ($keyword in $addLabelKeywords) { 97 | if ($message -match [regex]::Escape($keyword.Trim())) { 98 | $matchFound = $true 99 | break 100 | } 101 | } 102 | 103 | if ($matchFound) { 104 | Handle-OtherIssue -payload $payload 105 | } else { 106 | Log-Message -Type "WRN" -Message "Received issue is not handled." 107 | } 108 | 109 | } elseif ($payload.ContainsKey("series") -and $payload.ContainsKey("eventType")) { 110 | switch ($payload["eventType"]) { 111 | "Download" { 112 | Handle-SonarrEpisodeFileAdded -payload $payload 113 | } 114 | default { 115 | Log-Message -Type "INF" -Message "Unhandled Sonarr event type: $($payload["eventType"])" 116 | } 117 | } 118 | 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /functions/Log-Message.ps1: -------------------------------------------------------------------------------- 1 | function Log-Message { 2 | param ( 3 | [string]$Type, 4 | [string]$Message 5 | ) 6 | 7 | # ANSI escape codes for colors 8 | $ColorReset = "`e[0m" 9 | $ColorBlue = "`e[34m" 10 | $ColorRed = "`e[31m" 11 | $ColorYellow = "`e[33m" 12 | $ColorGray = "`e[90m" 13 | $ColorGreen = "`e[32m" 14 | $ColorDefault = "`e[37m" 15 | 16 | # Select color based on message type 17 | $color = switch ($Type) { 18 | "INF" { $ColorBlue } 19 | "ERR" { $ColorRed } 20 | "WRN" { $ColorYellow } 21 | "DBG" { $ColorGray } 22 | "SUC" { $ColorGreen } 23 | default { $ColorDefault } 24 | } 25 | 26 | # Output the colored type and uncolored message 27 | Write-Host "$($color)[$Type]$($ColorReset) $Message" 28 | } 29 | -------------------------------------------------------------------------------- /functions/Map-LanguageCode.ps1: -------------------------------------------------------------------------------- 1 | function Map-LanguageCode { 2 | param ([string]$languageCode, [hashtable]$languageMap) 3 | return $languageMap[$languageCode] 4 | } 5 | -------------------------------------------------------------------------------- /functions/Post-OverseerrComment.ps1: -------------------------------------------------------------------------------- 1 | function Post-OverseerrComment { 2 | param ([string]$issueId, [string]$message, [string]$overseerrApiKey, [string]$overseerrUrl) 3 | $url = "$overseerrUrl/issue/$issueId/comment" 4 | $body = @{ message = $message } | ConvertTo-Json 5 | 6 | try { 7 | $response = Invoke-RestMethod -Uri $url -Method Post -Body $body -ContentType "application/json" -Headers @{ 'X-Api-Key' = $overseerrApiKey } 8 | Write-Host "Posted comment to Overseerr issue $issueId" 9 | } catch { 10 | Write-Host "Failed to post comment to Overseerr: $_" 11 | } 12 | } -------------------------------------------------------------------------------- /functions/Process-Queue.ps1: -------------------------------------------------------------------------------- 1 | function Process-Queue { 2 | $anyPayloadHandled = $false # Tracks if any payload in this run was handled 3 | 4 | while ($queue.Count -gt 0) { 5 | $jsonPayload = $queue.Dequeue() 6 | try { 7 | $payload = $jsonPayload | ConvertFrom-Json -AsHashtable 8 | $payloadHandled = $false 9 | 10 | if ($payload.ContainsKey("event") -and $payload["event"] -eq "library.new" -and $payload["Metadata"]["librarySectionType"] -eq "show" -and $payload["Metadata"]["type"] -eq "episode") { 11 | $title = $payload["Metadata"]["grandparentTitle"] 12 | $season = $payload["Metadata"]["parentIndex"] 13 | $episode = $payload["Metadata"]["index"] 14 | $episodeTitle = $payload["Metadata"]["title"] 15 | Log-Message -Type "INF" -Message "📺 New episode added: $title - S$("{0:D2}" -f $season)E$("{0:D2}" -f $episode) \"$episodeTitle\"" 16 | $payloadHandled = $true 17 | } elseif ($payload.ContainsKey("notification_type") -and $payload["notification_type"] -eq "MEDIA_AVAILABLE") { 18 | $subject = $payload["subject"] 19 | $username = $payload["request"]?["requestedBy_username"] ?? "Unknown" 20 | Log-Message -Type "INF" -Message "✅ Media available: $subject (Requested by: $username)" 21 | $payloadHandled = $true 22 | } elseif ($payload.ContainsKey("issue") -and $payload["issue"]["issue_type"] -eq "SUBTITLES") { 23 | $subject = $payload["subject"] 24 | $message = $payload["message"] 25 | $username = $payload["issue"]["reportedBy_username"] 26 | $season = ($payload["extra"] | Where-Object { $_.name -eq "Affected Season" }).value 27 | $episode = ($payload["extra"] | Where-Object { $_.name -eq "Affected Episode" }).value 28 | $location = "S$("{0:D2}" -f $season)E$("{0:D2}" -f $episode)" 29 | Log-Message -Type "INF" -Message "🗣 Subtitle issue reported by $username for $subject ($location): $message" 30 | $payloadHandled = $true 31 | } elseif ($payload.ContainsKey("issue") -and $payload["issue"]["issue_type"] -eq "OTHER") { 32 | $subject = $payload["subject"] 33 | $message = $payload["message"] 34 | $username = $payload["issue"]["reportedBy_username"] 35 | Log-Message -Type "INF" -Message "📌 Other issue by $username for ${subject}: $message" 36 | $payloadHandled = $true 37 | } elseif ($payload.ContainsKey("eventType") -and $payload.ContainsKey("series")) { 38 | $eventType = $payload["eventType"] 39 | $seriesTitle = $payload["series"]["title"] 40 | Log-Message -Type "INF" -Message "📡 Sonarr webhook: $eventType event for $seriesTitle" 41 | $payloadHandled = $true 42 | } 43 | 44 | if ($payloadHandled) { 45 | $anyPayloadHandled = $true 46 | } 47 | 48 | Handle-Webhook -jsonPayload $jsonPayload 49 | } catch { 50 | Log-Message -Type "ERR" -Message "Error processing payload: $_" 51 | } 52 | 53 | Start-Sleep -Seconds 5 54 | } 55 | 56 | if ($anyPayloadHandled) { 57 | Log-Message -Type "SUC" -Message "All payloads processed." 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /functions/Resolve-OverseerrIssue.ps1: -------------------------------------------------------------------------------- 1 | function Resolve-OverseerrIssue { 2 | param ([string]$issueId, [string]$overseerrApiKey, [string]$overseerrUrl) 3 | $url = "$overseerrUrl/issue/$issueId/resolved" 4 | 5 | Write-Host "Marking issue as resolved with URL: $url" 6 | 7 | try { 8 | $response = Invoke-RestMethod -Uri $url -Method Post -Headers @{ 'X-Api-Key' = $overseerrApiKey } 9 | Write-Host "Marked issue $issueId as resolved in Overseerr" 10 | } catch { 11 | Write-Host "Failed to mark issue as resolved in Overseerr: $_" 12 | } 13 | } -------------------------------------------------------------------------------- /functions/Set-AudioTrack.ps1: -------------------------------------------------------------------------------- 1 | function Set-AudioTrack { 2 | param ([string]$ratingKey) 3 | 4 | $tokenFile = "/mnt/usr/user_tokens.json" 5 | $audioPrefFile = "/mnt/usr/user_audio_pref.json" 6 | 7 | if (-not (Test-Path $tokenFile)) { 8 | Log-Message -Type "ERR" -Message "User token file not found: $tokenFile" 9 | return 10 | } 11 | 12 | if (-not (Test-Path $audioPrefFile)) { 13 | Log-Message -Type "ERR" -Message "Audio preference file not found: $audioPrefFile" 14 | return 15 | } 16 | 17 | try { 18 | $userTokens = Get-Content -Path $tokenFile | ConvertFrom-Json 19 | $userAudioPreferences = Get-Content -Path $audioPrefFile | ConvertFrom-Json 20 | } catch { 21 | Log-Message -Type "ERR" -Message "Error reading configuration files: $_" 22 | return 23 | } 24 | 25 | # Fetch media metadata 26 | $metadataUrl = "$plexHost/library/metadata/$ratingKey"+"?X-Plex-Token=$plexToken" 27 | try { 28 | $metadata = Invoke-RestMethod -Uri $metadataUrl -Method Get -ContentType "application/xml" 29 | } catch { 30 | Log-Message -Type "ERR" -Message "Error retrieving metadata. Status: $($_.Exception.Response.StatusCode) - $($_.Exception.Message)" 31 | return 32 | } 33 | 34 | # Determine if this is a TV Show 35 | $isShow = $false 36 | if ($metadata.MediaContainer.Directory.type -eq "show") { 37 | $isShow = $true 38 | Log-Message -Type "INF" -Message "Detected TV Show: $($metadata.MediaContainer.Directory.title)" 39 | 40 | # Fetch seasons 41 | $seasonsUrl = "$plexHost/library/metadata/$ratingKey/children"+"?X-Plex-Token=$plexToken" 42 | try { 43 | $seasonsMetadata = Invoke-RestMethod -Uri $seasonsUrl -Method Get -ContentType "application/xml" 44 | } catch { 45 | Log-Message -Type "ERR" -Message "Error retrieving seasons. Status: $($_.Exception.Response.StatusCode) - $($_.Exception.Message)" 46 | return 47 | } 48 | 49 | # Loop through seasons 50 | foreach ($season in $seasonsMetadata.MediaContainer.Directory) { 51 | $seasonKey = $season.ratingKey 52 | Log-Message -Type "INF" -Message "Processing Season: $($season.title) (RatingKey: $seasonKey)" 53 | 54 | # Fetch episodes for the season 55 | $episodesUrl = "$plexHost/library/metadata/$seasonKey/children"+"?X-Plex-Token=$plexToken" 56 | try { 57 | $episodesMetadata = Invoke-RestMethod -Uri $episodesUrl -Method Get -ContentType "application/xml" 58 | } catch { 59 | if ($_.Exception.Response.StatusCode -eq 404) { 60 | Log-Message -Type "WRN" -Message "Skipping missing season (RatingKey: $seasonKey) - Not found in Plex." 61 | continue # Skip to the next season instead of failing 62 | } else { 63 | Log-Message -Type "ERR" -Message "Error retrieving episodes for Season $($season.index). Status: $($_.Exception.Response.StatusCode) - $($_.Exception.Message)" 64 | continue 65 | } 66 | } 67 | 68 | # Loop through episodes 69 | foreach ($episode in $episodesMetadata.MediaContainer.Video) { 70 | Log-Message -Type "INF" -Message "Processing Episode: $($episode.title) (S$($episode.parentIndex)E$($episode.index))" 71 | Process-MediaItem -ratingKey $episode.ratingKey -userTokens $userTokens -userAudioPreferences $userAudioPreferences 72 | } 73 | } 74 | return 75 | } 76 | 77 | # If not a show, process it as a single media item 78 | Process-MediaItem -ratingKey $ratingKey -userTokens $userTokens -userAudioPreferences $userAudioPreferences 79 | } 80 | 81 | function Process-MediaItem { 82 | param ( 83 | [string]$ratingKey, 84 | [PSCustomObject]$userTokens, 85 | [PSCustomObject]$userAudioPreferences 86 | ) 87 | 88 | # Fetch media metadata 89 | $metadataUrl = "$plexHost/library/metadata/$ratingKey"+"?X-Plex-Token=$plexToken" 90 | try { 91 | $metadata = Invoke-RestMethod -Uri $metadataUrl -Method Get -ContentType "application/xml" 92 | } catch { 93 | Log-Message -Type "ERR" -Message "Error retrieving metadata for media item $ratingKey. Status: $($_.Exception.Response.StatusCode) - $($_.Exception.Message)" 94 | return 95 | } 96 | 97 | $video = $metadata.MediaContainer.Video 98 | if ($video -is [System.Array]) { $video = $video[0] } 99 | 100 | if (-not $video.Media) { 101 | Log-Message -Type "ERR" -Message "No media information found for ratingKey: $ratingKey" 102 | return 103 | } 104 | 105 | $part = $video.Media.Part 106 | if ($part -is [System.Array]) { $part = $part[0] } 107 | 108 | if (-not $part) { 109 | Log-Message -Type "ERR" -Message "No media part found for ratingKey: $ratingKey" 110 | return 111 | } 112 | 113 | $partId = $part.id 114 | $audioTracks = $part.Stream | Where-Object { $_.streamType -eq "2" } 115 | 116 | if (-not $audioTracks) { 117 | Log-Message -Type "ERR" -Message "No audio tracks found for media item $ratingKey" 118 | return 119 | } 120 | 121 | # Identify default track 122 | $defaultTrack = $audioTracks | Where-Object { $_.default -eq "1" } 123 | 124 | Log-Message -Type "INF" -Message "Available audio tracks: $($audioTracks | ForEach-Object { $_.extendedDisplayTitle })" 125 | 126 | # Cache already chosen tracks to minimize redundant operations 127 | $selectedTracksCache = @{} 128 | 129 | foreach ($user in $userTokens.PSObject.Properties) { 130 | $plexUsername = $user.Name 131 | $userToken = $user.Value 132 | 133 | if (-not $userAudioPreferences.PSObject.Properties[$plexUsername]) { 134 | Log-Message -Type "WRN" -Message "No audio track preference found for user: $plexUsername. Skipping." 135 | continue 136 | } 137 | 138 | $userPreferences = $userAudioPreferences.$plexUsername.preferred 139 | Log-Message -Type "INF" -Message "User '$plexUsername' prefers: $($userPreferences | ForEach-Object { "$($_.languageCode) ($($_.codec) $($_.channels))" })" 140 | 141 | $selectedTrack = $null 142 | 143 | # Step 1: Exact Match (Language, Codec, Channels) 144 | foreach ($pref in $userPreferences) { 145 | $selectedTrack = $audioTracks | Where-Object { 146 | $_.languageCode -eq $pref.languageCode -and $_.codec -eq $pref.codec -and $_.channels -eq $pref.channels 147 | } 148 | if ($selectedTrack) { break } 149 | } 150 | 151 | # Step 2: If no exact match, match language only 152 | if (-not $selectedTrack) { 153 | foreach ($pref in $userPreferences) { 154 | $selectedTrack = $audioTracks | Where-Object { 155 | $_.languageCode -eq $pref.languageCode 156 | } | Select-Object -First 1 157 | if ($selectedTrack) { break } 158 | } 159 | } 160 | 161 | # Step 3: Match by Channel Count if Language Not Found 162 | if (-not $selectedTrack) { 163 | foreach ($pref in $userPreferences) { 164 | $selectedTrack = $audioTracks | Where-Object { 165 | $_.channels -eq $pref.channels 166 | } | Select-Object -First 1 167 | if ($selectedTrack) { break } 168 | } 169 | } 170 | 171 | # Step 4: Use default track if still no match 172 | if (-not $selectedTrack -and $defaultTrack) { 173 | $selectedTrack = $defaultTrack 174 | } 175 | 176 | if (-not $selectedTrack) { 177 | Log-Message -Type "WRN" -Message "No suitable track found for user '$plexUsername'. Skipping." 178 | continue 179 | } 180 | 181 | $audioStreamId = $selectedTrack.id 182 | $updateUrl = "$plexHost/library/parts/$partId"+"?X-Plex-Token=$userToken&audioStreamID=$audioStreamId" 183 | try { 184 | Invoke-RestMethod -Uri $updateUrl -Method Put 185 | Log-Message -Type "SUC" -Message "Successfully set audio track for user '$plexUsername': $($selectedTrack.extendedDisplayTitle)" 186 | } catch { 187 | Log-Message -Type "ERR" -Message "Error setting audio track for user '$plexUsername'. Status: $($_.Exception.Response.StatusCode) - $($_.Exception.Message)" 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /functions/Set-SubtitleText.ps1: -------------------------------------------------------------------------------- 1 | function Set-SubtitleText { 2 | param ( 3 | [string]$subtitlePath, 4 | [string]$text, 5 | [string]$targetLang 6 | ) 7 | 8 | # Retrieve and sanitize environment variables 9 | $moviePathMapping = $env:MOVIE_PATH_MAPPING -replace "\\", "/" -replace "\s+$", "" 10 | $tvPathMapping = $env:TV_PATH_MAPPING -replace "\\", "/" -replace "\s+$", "" 11 | 12 | # Normalize subtitle path 13 | $subtitlePath = $subtitlePath -replace "\\", "/" 14 | 15 | # Debugging output before path replacement 16 | Write-Host "DEBUG: Checking '$subtitlePath' against '$moviePathMapping'" 17 | Write-Host "DEBUG: Checking '$subtitlePath' against '$tvPathMapping'" 18 | 19 | # Match and replace paths 20 | if ($subtitlePath.StartsWith($moviePathMapping)) { 21 | Write-Host "DEBUG: Movie path detected. Replacing '$moviePathMapping' with '/mnt/movies'" 22 | $subtitlePath = $subtitlePath -replace [regex]::Escape($moviePathMapping), "/mnt/movies" 23 | } elseif ($subtitlePath.StartsWith($tvPathMapping)) { 24 | Write-Host "DEBUG: TV path detected. Replacing '$tvPathMapping' with '/mnt/tv'" 25 | $subtitlePath = $subtitlePath -replace [regex]::Escape($tvPathMapping), "/mnt/tv" 26 | } else { 27 | Write-Host "ERROR: No matching path found for subtitle path." 28 | } 29 | 30 | # Extract file name and directory path 31 | $fileName = [System.IO.Path]::GetFileNameWithoutExtension($subtitlePath) 32 | $directoryPath = [System.IO.Path]::GetDirectoryName($subtitlePath) 33 | 34 | # Extract and replace language code 35 | if ($fileName -match "\.\w{2,3}$") { 36 | $fileName = $fileName -replace "\.\w{2,3}$", "" 37 | } 38 | 39 | # Build new subtitle path 40 | $newSubtitlePath = [System.IO.Path]::Combine($directoryPath, "$fileName.$targetLang.srt") 41 | 42 | try { 43 | # Ensure directory exists 44 | if (-not (Test-Path -Path $directoryPath)) { 45 | New-Item -ItemType Directory -Path $directoryPath | Out-Null 46 | Write-Host "Created missing directory: $directoryPath" 47 | } 48 | 49 | # Save the subtitle file 50 | Set-Content -LiteralPath $newSubtitlePath -Value $text 51 | Write-Host "Successfully saved subtitle file: $newSubtitlePath" 52 | } catch { 53 | Write-Host "Failed to write to subtitle file: $_" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /functions/Set-SubtitleTrack.ps1: -------------------------------------------------------------------------------- 1 | function Set-SubtitleTrack { 2 | param ([string]$ratingKey) 3 | 4 | $tokenFile = "/mnt/usr/user_tokens.json" 5 | $subtitlePrefFile = "/mnt/usr/user_subs_pref.json" 6 | 7 | if (-not (Test-Path $tokenFile)) { 8 | Log-Message -Type "ERR" -Message "User token file not found: $tokenFile" 9 | return 10 | } 11 | 12 | if (-not (Test-Path $subtitlePrefFile)) { 13 | Log-Message -Type "ERR" -Message "Subtitle preference file not found: $subtitlePrefFile" 14 | return 15 | } 16 | 17 | try { 18 | $userTokens = Get-Content -Path $tokenFile | ConvertFrom-Json 19 | $userSubtitlePreferences = Get-Content -Path $subtitlePrefFile | ConvertFrom-Json 20 | } catch { 21 | Log-Message -Type "ERR" -Message "Error reading configuration files: $_" 22 | return 23 | } 24 | 25 | # Fetch media metadata 26 | $metadataUrl = "$plexHost/library/metadata/$ratingKey"+"?X-Plex-Token=$plexToken" 27 | try { 28 | $metadata = Invoke-RestMethod -Uri $metadataUrl -Method Get -ContentType "application/xml" 29 | } catch { 30 | Log-Message -Type "ERR" -Message "Error retrieving metadata. Status: $($_.Exception.Response.StatusCode) - $($_.Exception.Message)" 31 | return 32 | } 33 | 34 | # Determine if this is a TV Show 35 | $isShow = $false 36 | if ($metadata.MediaContainer.Directory.type -eq "show") { 37 | $isShow = $true 38 | Log-Message -Type "INF" -Message "Detected TV Show: $($metadata.MediaContainer.Directory.title)" 39 | 40 | # Fetch seasons 41 | $seasonsUrl = "$plexHost/library/metadata/$ratingKey/children"+"?X-Plex-Token=$plexToken" 42 | try { 43 | $seasonsMetadata = Invoke-RestMethod -Uri $seasonsUrl -Method Get -ContentType "application/xml" 44 | } catch { 45 | Log-Message -Type "ERR" -Message "Error retrieving seasons. Status: $($_.Exception.Response.StatusCode) - $($_.Exception.Message)" 46 | return 47 | } 48 | 49 | # Loop through seasons 50 | foreach ($season in $seasonsMetadata.MediaContainer.Directory) { 51 | $seasonKey = $season.ratingKey 52 | Log-Message -Type "INF" -Message "Processing Season: $($season.title) (RatingKey: $seasonKey)" 53 | 54 | # Fetch episodes for the season 55 | $episodesUrl = "$plexHost/library/metadata/$seasonKey/children"+"?X-Plex-Token=$plexToken" 56 | try { 57 | $episodesMetadata = Invoke-RestMethod -Uri $episodesUrl -Method Get -ContentType "application/xml" 58 | } catch { 59 | Log-Message -Type "ERR" -Message "Error retrieving episodes for Season $($season.index). Status: $($_.Exception.Response.StatusCode) - $($_.Exception.Message)" 60 | continue 61 | } 62 | 63 | # Loop through episodes 64 | foreach ($episode in $episodesMetadata.MediaContainer.Video) { 65 | Log-Message -Type "INF" -Message "Processing Episode: $($episode.title) (S$($episode.parentIndex)E$($episode.index))" 66 | Process-SubtitleTrack -ratingKey $episode.ratingKey -userTokens $userTokens -userSubtitlePreferences $userSubtitlePreferences 67 | } 68 | } 69 | return 70 | } 71 | 72 | # If not a show, process it as a single media item 73 | Process-SubtitleTrack -ratingKey $ratingKey -userTokens $userTokens -userSubtitlePreferences $userSubtitlePreferences 74 | } 75 | 76 | function Process-SubtitleTrack { 77 | param ( 78 | [string]$ratingKey, 79 | [PSCustomObject]$userTokens, 80 | [PSCustomObject]$userSubtitlePreferences 81 | ) 82 | 83 | # Fetch media metadata 84 | $metadataUrl = "$plexHost/library/metadata/$ratingKey"+"?X-Plex-Token=$plexToken" 85 | try { 86 | $metadata = Invoke-RestMethod -Uri $metadataUrl -Method Get -ContentType "application/xml" 87 | } catch { 88 | Log-Message -Type "ERR" -Message "Error retrieving metadata for media item $ratingKey. Status: $($_.Exception.Response.StatusCode) - $($_.Exception.Message)" 89 | return 90 | } 91 | 92 | $video = $metadata.MediaContainer.Video 93 | if ($video -is [System.Array]) { $video = $video[0] } 94 | 95 | if (-not $video.Media) { 96 | Log-Message -Type "ERR" -Message "No media information found for ratingKey: $ratingKey" 97 | return 98 | } 99 | 100 | $part = $video.Media.Part 101 | if ($part -is [System.Array]) { $part = $part[0] } 102 | 103 | if (-not $part) { 104 | Log-Message -Type "ERR" -Message "No media part found for ratingKey: $ratingKey" 105 | return 106 | } 107 | 108 | $partId = $part.id 109 | $subtitleTracks = $part.Stream | Where-Object { $_.streamType -eq "3" } 110 | 111 | if (-not $subtitleTracks) { 112 | Log-Message -Type "WRN" -Message "No subtitles found for media item $ratingKey" 113 | return 114 | } 115 | 116 | Log-Message -Type "INF" -Message "Available subtitle tracks: $($subtitleTracks | ForEach-Object { $_.extendedDisplayTitle })" 117 | 118 | foreach ($user in $userTokens.PSObject.Properties) { 119 | $plexUsername = $user.Name 120 | $userToken = $user.Value 121 | 122 | if (-not $userSubtitlePreferences.PSObject.Properties[$plexUsername]) { 123 | Log-Message -Type "WRN" -Message "No subtitle preference found for user: $plexUsername. Skipping." 124 | continue 125 | } 126 | 127 | $userPreferences = $userSubtitlePreferences.$plexUsername.preferred 128 | $fallbackConfig = $userSubtitlePreferences.$plexUsername.fallback 129 | $fallbackEnabled = $fallbackConfig.enabled -eq $true 130 | $fallbackPreferences = $fallbackConfig.preferences 131 | 132 | Log-Message -Type "INF" -Message "User '$plexUsername' prefers: $($userPreferences | ForEach-Object { "$($_.languageCode) ($($_.codec)) Forced: $($_.forced) HI: $($_.hearingImpaired)" }) Fallback enabled: $fallbackEnabled" 133 | 134 | $selectedTrack = $null 135 | 136 | # Step 1: Exact Match (LanguageCode, Codec, Forced, HearingImpaired) 137 | foreach ($pref in $userPreferences) { 138 | $selectedTrack = $subtitleTracks | Where-Object { 139 | $_.languageCode -eq $pref.languageCode -and 140 | $_.codec -eq $pref.codec -and 141 | (([bool]$_.forced -or $false) -eq $pref.forced) -and 142 | (([bool]$_.hearingImpaired -or $false) -eq $pref.hearingImpaired) 143 | } | Select-Object -First 1 144 | if ($selectedTrack) { break } 145 | } 146 | 147 | # Step 2: Apply Fallback if Enabled 148 | if (-not $selectedTrack -and $fallbackEnabled) { 149 | Log-Message -Type "INF" -Message "No preferred subtitles found. Fallback is enabled for user '$plexUsername'. Checking fallback preferences." 150 | foreach ($fallbackPref in $fallbackPreferences) { 151 | $selectedTrack = $subtitleTracks | Where-Object { 152 | $_.languageCode -eq $fallbackPref.languageCode -and 153 | $_.codec -eq $fallbackPref.codec -and 154 | (([bool]$_.forced -or $false) -eq $fallbackPref.forced) -and 155 | (([bool]$_.hearingImpaired -or $false) -eq $fallbackPref.hearingImpaired) 156 | } | Select-Object -First 1 157 | if ($selectedTrack) { 158 | Log-Message -Type "INF" -Message "Fallback track selected for user '$plexUsername': $($selectedTrack.extendedDisplayTitle)" 159 | break 160 | } 161 | } 162 | } 163 | 164 | # Step 3: Set to "No Subtitles" if No Track Found 165 | if (-not $selectedTrack) { 166 | Log-Message -Type "WRN" -Message "No suitable subtitle track found for user '$plexUsername'. Disabling subtitles." 167 | 168 | # Set to "no subtitles" by specifying the stream ID as 0 169 | $updateUrl = "$plexHost/library/parts/$partId"+"?X-Plex-Token=$userToken&subtitleStreamID=0" 170 | 171 | try { 172 | Invoke-RestMethod -Uri $updateUrl -Method Put 173 | Log-Message -Type "SUC" -Message "No subtitles set for user '$plexUsername'." 174 | } catch { 175 | Log-Message -Type "ERR" -Message "Error disabling subtitles for user '$plexUsername'. Status: $($_.Exception.Response.StatusCode) - $($_.Exception.Message)" 176 | } 177 | 178 | continue 179 | } 180 | 181 | # Step 4: Set Subtitle Track 182 | $subtitleStreamId = $selectedTrack.id 183 | $updateUrl = "$plexHost/library/parts/$partId"+"?X-Plex-Token=$userToken&subtitleStreamID=$subtitleStreamId" 184 | 185 | try { 186 | Invoke-RestMethod -Uri $updateUrl -Method Put 187 | Log-Message -Type "SUC" -Message "Successfully set subtitle track for user '$plexUsername': $($selectedTrack.extendedDisplayTitle)" 188 | } catch { 189 | Log-Message -Type "ERR" -Message "Error setting subtitle track for user '$plexUsername'. Status: $($_.Exception.Response.StatusCode) - $($_.Exception.Message)" 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /functions/ShiftOffset.ps1: -------------------------------------------------------------------------------- 1 | function ShiftOffset { 2 | param ( 3 | [string]$message 4 | ) 5 | 6 | $pattern = "adjust\s+by\s+(-?\d+)\s*(s|ms|m|min)?" 7 | 8 | Write-Host "Matching pattern: $pattern against message: '$message'" 9 | if ($message -match $pattern) { 10 | Write-Host "Pattern matched!" 11 | $number = [int]$matches[1] 12 | $unit = if ($matches[2]) { $matches[2] } else { "s" } # default to seconds if no unit is specified 13 | 14 | Write-Host "Number: $number, Unit: $unit" 15 | 16 | # Determine the unit and convert to Bazarr format 17 | switch ($unit) { 18 | "ms" { return "h=0,m=0,s=0,ms=$number" } 19 | "m" { return "h=0,m=$number,s=0,ms=0" } 20 | "min" { return "h=0,m=$number,s=0,ms=0" } 21 | default { return "h=0,m=0,s=$number,ms=0" } # seconds by default 22 | } 23 | } else { 24 | Write-Host "Pattern did not match." 25 | } 26 | 27 | return $null 28 | } -------------------------------------------------------------------------------- /functions/Start-OverseerrRequestMonitor.ps1: -------------------------------------------------------------------------------- 1 | function Start-OverseerrRequestMonitor { 2 | param ( 3 | [string]$overseerrUrl, 4 | [string]$plexHost, 5 | [string]$plexToken, 6 | [string]$overseerrApiKey, 7 | [string]$seriesSectionId, 8 | [string]$animeSectionId 9 | ) 10 | 11 | function Add-TagToMedia { 12 | param ( 13 | [string]$newTag, 14 | [string]$ratingKey 15 | ) 16 | 17 | $metadataUrl = "$plexHost/library/metadata/$ratingKey"+"?X-Plex-Token=$plexToken" 18 | try { 19 | $metadata = Invoke-RestMethod -Uri $metadataUrl -Method Get -ContentType "application/xml" 20 | } catch { 21 | Write-Host "Error retrieving metadata: $_" 22 | return 23 | } 24 | 25 | $currentLabels = @() 26 | 27 | if ($metadata.MediaContainer.Video.Label) { 28 | if ($metadata.MediaContainer.Video.Label -is [System.Array]) { 29 | $currentLabels = $metadata.MediaContainer.Video.Label | ForEach-Object { $_.tag } 30 | } else { 31 | $currentLabels = @($metadata.MediaContainer.Video.Label.tag) 32 | } 33 | } elseif ($metadata.MediaContainer.Directory.Label) { 34 | if ($metadata.MediaContainer.Directory.Label -is [System.Array]) { 35 | $currentLabels = $metadata.MediaContainer.Directory.Label | ForEach-Object { $_.tag } 36 | } else { 37 | $currentLabels = @($metadata.MediaContainer.Directory.Label.tag) 38 | } 39 | } 40 | 41 | if (-not ($currentLabels -contains $newTag)) { 42 | $currentLabels += $newTag 43 | } else { 44 | Write-Host "Label '$newTag' already exists. Skipping." 45 | return 46 | } 47 | 48 | $encodedLabels = $currentLabels | ForEach-Object { 49 | $index = $currentLabels.IndexOf($_) 50 | "label[$index].tag.tag=" + [System.Uri]::EscapeDataString($_) 51 | } 52 | 53 | $encodedLabelsString = $encodedLabels -join "&" 54 | $updateUrl = "$plexHost/library/metadata/$ratingKey"+"?X-Plex-Token=$plexToken&$encodedLabelsString&label.locked=1" 55 | 56 | try { 57 | Invoke-RestMethod -Uri $updateUrl -Method Put 58 | Write-Host "Label '$newTag' added to media item $ratingKey." 59 | } catch { 60 | Write-Host "Failed to update tags for ratingKey ${ratingKey}: $_" 61 | Write-Host "Request URL: $updateUrl" 62 | } 63 | } 64 | 65 | try { 66 | $headers = @{ 'X-Api-Key' = $overseerrApiKey } 67 | $page = 1 68 | $take = 50 69 | $allRequests = @() 70 | 71 | do { 72 | $skip = ($page - 1) * $take 73 | $requestUrl = "$overseerrUrl/request?take=$take&skip=$skip&filter=all&sort=added&sortDirection=desc" 74 | Write-Host "Fetching page ${page}: $requestUrl" 75 | 76 | $response = Invoke-RestMethod -Uri $requestUrl -Method Get -Headers $headers 77 | 78 | if ($response.results) { 79 | $allRequests += $response.results 80 | } 81 | 82 | $hasMorePages = $page -lt $response.pageInfo.pages 83 | $page++ 84 | 85 | } while ($hasMorePages) 86 | 87 | if (-not $allRequests) { 88 | Write-Host "No requests found." 89 | return 90 | } 91 | 92 | $matchingRequests = $allRequests | Where-Object { $_.media.status -in 4, 5 } 93 | 94 | if (-not $matchingRequests) { 95 | Write-Host "No approved or available requests to process." 96 | return 97 | } 98 | 99 | foreach ($request in $matchingRequests) { 100 | $mediaType = $request.media.mediaType 101 | $tmdbId = $request.media.tmdbId 102 | $tvdbId = $request.media.tvdbId 103 | $plexUsername = $request.requestedBy.plexUsername 104 | $ratingKey = $request.media.ratingKey 105 | 106 | if (-not $ratingKey) { 107 | Write-Host "Skipping item with no ratingKey." 108 | continue 109 | } 110 | 111 | Write-Host "Processing $mediaType (ratingKey: $ratingKey) for user: $plexUsername" 112 | 113 | Add-TagToMedia -newTag $plexUsername -ratingKey $ratingKey 114 | Set-AudioTrack -ratingKey $ratingKey -seriesSectionId $seriesSectionId -animeSectionId $animeSectionId 115 | Set-SubtitleTrack -ratingKey $ratingKey -seriesSectionId $seriesSectionId -animeSectionId $animeSectionId 116 | } 117 | 118 | Write-Host "Finished processing all Overseerr partially available and available requests." 119 | 120 | } catch { 121 | Write-Host "An error occurred while processing Overseerr requests: $_" 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /functions/Translate-Message.ps1: -------------------------------------------------------------------------------- 1 | function Translate-Message { 2 | param ( 3 | [string]$key, 4 | [string]$language 5 | ) 6 | 7 | # Translation dictionary 8 | $translations = @{ 9 | "en" = @{ 10 | "SubtitleIssueDetected" = "Subtitle issue detected" 11 | "SyncStarted" = "Syncing of $languageName subtitles started." 12 | "SyncFinished" = "$languageName subtitles have been synced." 13 | "TranslationStarted" = "Translation of $sourceLanguageName to $targetLanguageName subtitles started." 14 | "TranslationFinished" = "Translation of subtitles finished." 15 | "GptTranslateStart" = "Translation of $sourceLanguageName to $targetLanguageName subtitles started using GPT." 16 | "FailedToParseLanguages" = "Failed to parse source and target languages" 17 | "SubtitlesMissing" = "Subtitles are either embedded or missing - cannot sync." 18 | "SubtitlesPartiallySynced" = "Subtitles synced with the exception of: episode" 19 | } 20 | "da" = @{ 21 | "SubtitleIssueDetected" = "Undertekst problem opdaget" 22 | "SyncStarted" = "Synkronisering af $languageName undertekster startet." 23 | "SyncFinished" = "$languageName undertekster er blevet synkroniseret." 24 | "TranslationStarted" = "Oversættelse af $sourceLanguageName til $targetLanguageName undertekster startet." 25 | "GptTranslateStart" = "Oversættelse af $sourceLanguageName til $targetLanguageName undertekster startet ved brug af GPT." 26 | "TranslationFinished" = "Oversættelse af undertekster afsluttet." 27 | "FailedToParseLanguages" = "Kunne ikke fortolke kilde- og målsprog" 28 | "SubtitlesMissing" = "Undertekster er enten indlejret eller mangler - kan ikke synkronisere." 29 | "SubtitlesPartiallySynced" = "Undertekster synkroniseret med undtagelse af: episode" 30 | } 31 | "de" = @{ 32 | "SubtitleIssueDetected" = "Untertitelproblem erkannt" 33 | "SyncStarted" = "Synchronisierung der $languageName Untertitel gestartet." 34 | "SyncFinished" = "$languageName Untertitel wurden synchronisiert." 35 | "TranslationStarted" = "Übersetzung der $sourceLanguageName zu $targetLanguageName Untertitel gestartet." 36 | "GptTranslateStart" = "Übersetzung der $sourceLanguageName zu $targetLanguageName Untertitel gestartet mit GPT." 37 | "TranslationFinished" = "Übersetzung der Untertitel abgeschlossen." 38 | "FailedToParseLanguages" = "Quell- und Zielsprache konnten nicht analysiert werden" 39 | "SubtitlesMissing" = "Untertitel sind entweder eingebettet oder fehlen - kann nicht synchronisieren." 40 | "SubtitlesPartiallySynced" = "Untertitel synchronisiert mit Ausnahme von: Episode" 41 | } 42 | "no" = @{ 43 | "SubtitleIssueDetected" = "Undertekstproblem oppdaget" 44 | "SyncStarted" = "Synkronisering av $languageName undertekster startet." 45 | "SyncFinished" = "$languageName undertekster har blitt synkronisert." 46 | "TranslationStarted" = "Oversettelse av $sourceLanguageName til $targetLanguageName undertekster startet." 47 | "GptTranslateStart" = "Oversettelse av $sourceLanguageName til $targetLanguageName undertekster startet med GPT." 48 | "TranslationFinished" = "Oversettelse av undertekster fullført." 49 | "FailedToParseLanguages" = "Kunne ikke analysere kilde- og målspråk" 50 | "SubtitlesMissing" = "Undertekster er enten innebygd eller mangler - kan ikke synkroniseres." 51 | "SubtitlesPartiallySynced" = "Undertekster synkronisert med unntak av: episode" 52 | } 53 | "sv" = @{ 54 | "SubtitleIssueDetected" = "Undertextproblem upptäckt" 55 | "SyncStarted" = "Synkronisering av $languageName undertexter startad." 56 | "SyncFinished" = "$languageName undertexter har synkroniserats." 57 | "TranslationStarted" = "Översättning av $sourceLanguageName till $targetLanguageName undertexter startad." 58 | "GptTranslateStart" = "Översättning av $sourceLanguageName till $targetLanguageName undertexter startad med GPT." 59 | "TranslationFinished" = "Översättning av undertexter avslutad." 60 | "FailedToParseLanguages" = "Misslyckades med att tolka käll- och målspråk" 61 | "SubtitlesMissing" = "Undertexter är antingen inbäddade eller saknas - kan inte synkronisera." 62 | "SubtitlesPartiallySynced" = "Undertexter synkroniserade med undantag för: avsnitt" 63 | } 64 | "es" = @{ 65 | "SubtitleIssueDetected" = "Problema de subtítulos detectado" 66 | "SyncStarted" = "Sincronización de subtítulos en $languageName iniciada." 67 | "SyncFinished" = "Los subtítulos en $languageName se han sincronizado." 68 | "TranslationStarted" = "Traducción de subtítulos de $sourceLanguageName a $targetLanguageName iniciada." 69 | "GptTranslateStart" = "Traducción de subtítulos de $sourceLanguageName a $targetLanguageName iniciada usando GPT." 70 | "TranslationFinished" = "Traducción de subtítulos finalizada." 71 | "FailedToParseLanguages" = "No se pudieron analizar los idiomas de origen y destino" 72 | "SubtitlesMissing" = "Los subtítulos están incrustados o faltan - no se pueden sincronizar." 73 | "SubtitlesPartiallySynced" = "Subtítulos sincronizados con la excepción de: episodio" 74 | } 75 | "fr" = @{ 76 | "SubtitleIssueDetected" = "Problème de sous-titres détecté" 77 | "SyncStarted" = "Synchronisation des sous-titres en $languageName commencée." 78 | "SyncFinished" = "Les sous-titres en $languageName ont été synchronisés." 79 | "TranslationStarted" = "Traduction des sous-titres de $sourceLanguageName en $targetLanguageName commencée." 80 | "GptTranslateStart" = "Traduction des sous-titres de $sourceLanguageName en $targetLanguageName commencée avec GPT." 81 | "TranslationFinished" = "Traduction des sous-titres terminée." 82 | "FailedToParseLanguages" = "Impossible d'analyser les langues source et cible" 83 | "SubtitlesMissing" = "Les sous-titres sont soit intégrés, soit manquants - impossible de synchroniser." 84 | "SubtitlesPartiallySynced" = "Sous-titres synchronisés avec l'exception de : épisode" 85 | } 86 | "bg" = @{ 87 | "SubtitleIssueDetected" = "Открит проблем със субтитрите" 88 | "SyncStarted" = "Синхронизацията на $languageName субтитри започна." 89 | "SyncFinished" = "$languageName субтитри са синхронизирани." 90 | "TranslationStarted" = "Преводът на субтитрите от $sourceLanguageName на $targetLanguageName започна." 91 | "GptTranslateStart" = "Преводът на субтитрите от $sourceLanguageName на $targetLanguageName започна чрез GPT." 92 | "TranslationFinished" = "Преводът на субтитрите завърши." 93 | "FailedToParseLanguages" = "Неуспешно разчитане на изходен и целеви език" 94 | "SubtitlesMissing" = "Субтитрите са вградени или липсват - не може да се синхронизират." 95 | "SubtitlesPartiallySynced" = "Субтитрите са синхронизирани с изключение на: епизод" 96 | } 97 | "it" = @{ 98 | "SubtitleIssueDetected" = "Problema dei sottotitoli rilevato" 99 | "SyncStarted" = "Sincronizzazione dei sottotitoli in $languageName avviata." 100 | "SyncFinished" = "I sottotitoli in $languageName sono stati sincronizzati." 101 | "TranslationStarted" = "Traduzione dei sottotitoli da $sourceLanguageName a $targetLanguageName avviata." 102 | "GptTranslateStart" = "Traduzione dei sottotitoli da $sourceLanguageName a $targetLanguageName avviata con GPT." 103 | "TranslationFinished" = "Traduzione dei sottotitoli completata." 104 | "FailedToParseLanguages" = "Impossibile analizzare le lingue di origine e di destinazione" 105 | "SubtitlesMissing" = "I sottotitoli sono incorporati o mancanti - impossibile sincronizzare." 106 | "SubtitlesPartiallySynced" = "Sottotitoli sincronizzati con l'eccezione di: episodio" 107 | } 108 | "hy" = @{ 109 | "SubtitleIssueDetected" = "Ենթավերնագիրների խնդիր հայտնաբերվել է" 110 | "SyncStarted" = "$languageName ենթավերնագրերի համաժամանակեցումը սկսվել է." 111 | "SyncFinished" = "$languageName ենթավերնագրերը համաժամանակեցված են." 112 | "TranslationStarted" = "$sourceLanguageName-ից $targetLanguageName ենթավերնագրերի թարգմանությունը սկսվել է." 113 | "GptTranslateStart" = "$sourceLanguageName-ից $targetLanguageName ենթավերնագրերի GPT-թարգմանությունը սկսվել է։" 114 | "TranslationFinished" = "Ենթավերնագրերի թարգմանությունը ավարտված է." 115 | "FailedToParseLanguages" = "Չհաջողվեց վերլուծել սկզբնական և նպատակային լեզուները" 116 | "SubtitlesMissing" = "Ենթավերնագրերը կա՛մ ներդրված են, կա՛մ բացակայում են՝ չի հաջողվել համաժամանակեցնել." 117 | "SubtitlesPartiallySynced" = "Ենթավերնագրերը համաժամանակեցված են, բացառությամբ՝ դրվագ" 118 | } 119 | "as" = @{ 120 | "SubtitleIssueDetected" = "সাবটাইটেল সমস্যা চিহ্নিত হৈছে" 121 | "SyncStarted" = "$languageNameৰ সাবটাইটেলসমূহৰ চিঙ্ক্ৰোনাইজেচন আৰম্ভ হৈছে." 122 | "SyncFinished" = "$languageNameৰ সাবটাইটেলসমূহ চিঙ্ক্ৰোনাইজড হৈছে." 123 | "TranslationStarted" = "$sourceLanguageNameৰ পৰা $targetLanguageNameলৈ সাবটাইটেলসমূহৰ অনুবাদ আৰম্ভ হৈছে." 124 | "GptTranslateStart" = "$sourceLanguageNameৰ পৰা $targetLanguageNameলৈ সাবটাইটেলসমূহৰ GPT অনুবাদ আৰম্ভ হৈছে।" 125 | "TranslationFinished" = "সাবটাইটেলসমূহৰ অনুবাদ সমাপ্ত হৈছে." 126 | "FailedToParseLanguages" = "উৎপত্তি আৰু লক্ষ্য ভাষাসমূহৰ বিশ্লেষণ বিফল হৈছে" 127 | "SubtitlesMissing" = "সাবটাইটেলসমূহ এম্বেড কৰা হৈছে বা অনুপস্থিত আছে - চিঙ্ক্ৰোনাইজ কৰিব নোৱাৰি." 128 | "SubtitlesPartiallySynced" = "সাবটাইটেলসমূহ ডাঙৰ কৰাৰ সময়ত সিঙ্ক্ৰোনাইজড হৈছে, সৰ্বভাৰতীয় বেতাৰ: অধ্যায়" 129 | } 130 | "ba" = @{ 131 | "SubtitleIssueDetected" = "Титрҙарҙағы мәсьәлә асыҡланды" 132 | "SyncStarted" = "$languageName титрҙары синхронлаштырыла башланы." 133 | "SyncFinished" = "$languageName титрҙары синхронлаштырылды." 134 | "TranslationStarted" = "$sourceLanguageName-тан $targetLanguageName титрҙары тәржемә ителә башланы." 135 | "GptTranslateStart" = "$sourceLanguageName-тан $targetLanguageName титрҙары GPT ярҙамында тәржемә ителә башланы." 136 | "TranslationFinished" = "Титрҙарҙың тәржемәһе тамамланды." 137 | "FailedToParseLanguages" = "Тәржемә сығанаҡ һәм маҡсат телдәрен анализлау уңышһыҙ булды" 138 | "SubtitlesMissing" = "Титрҙар ендәшмәләре йәки юҡ - синхронлаштырыу мөмкин түгел." 139 | "SubtitlesPartiallySynced" = "Титрҙар синхронлаштырылды, тик эпизодтар юҡ" 140 | } 141 | "bn" = @{ 142 | "SubtitleIssueDetected" = "সাবটাইটেল সমস্যা সনাক্ত করা হয়েছে" 143 | "SyncStarted" = "$languageName সাবটাইটেলগুলির সিঙ্ক্রোনাইজেশন শুরু হয়েছে." 144 | "SyncFinished" = "$languageName সাবটাইটেলগুলি সিঙ্ক্রোনাইজ করা হয়েছে." 145 | "TranslationStarted" = "$sourceLanguageName থেকে $targetLanguageName সাবটাইটেলের অনুবাদ শুরু হয়েছে." 146 | "GptTranslateStart" = "$sourceLanguageName থেকে $targetLanguageName সাবটাইটেলের GPT অনুবাদ শুরু হয়েছে।" 147 | "TranslationFinished" = "সাবটাইটেলগুলির অনুবাদ শেষ হয়েছে." 148 | "FailedToParseLanguages" = "উৎস এবং লক্ষ্য ভাষা বিশ্লেষণে ব্যর্থ হয়েছে" 149 | "SubtitlesMissing" = "সাবটাইটেলগুলি এম্বেড করা হয়েছে বা অনুপস্থিত - সিঙ্ক্রোনাইজ করা যাবে না." 150 | "SubtitlesPartiallySynced" = "সাবটাইটেলগুলি সিঙ্ক্রোনাইজ করা হয়েছে ব্যতিক্রম সহ: পর্ব" 151 | } 152 | "bi" = @{ 153 | "SubtitleIssueDetected" = "Ishiu blong subtitles I stap" 154 | "SyncStarted" = "Syncronising blong $languageName subtitles I stat." 155 | "SyncFinished" = "$languageName subtitles I bin sync." 156 | "TranslationStarted" = "Translating blong $sourceLanguageName to $targetLanguageName subtitles I stat." 157 | "GptTranslateStart" = "Translating blong $sourceLanguageName to $targetLanguageName subtitles I stat wetem GPT." 158 | "TranslationFinished" = "Translation blong subtitles I finis." 159 | "FailedToParseLanguages" = "Fail blong analyze source mo target languages" 160 | "SubtitlesMissing" = "Subtitles I embedded o I lus - I no save sync." 161 | "SubtitlesPartiallySynced" = "Subtitles I sync but not all: episode" 162 | } 163 | "br" = @{ 164 | "SubtitleIssueDetected" = "Kudennoù titloù zo bet kavet" 165 | "SyncStarted" = "Kregiñ zo bet graet gant sinkronizadur titloù $languageName." 166 | "SyncFinished" = "Titloù $languageName zo bet sinkronizet." 167 | "TranslationStarted" = "Kregiñ zo bet graet gant an droidigezh a $sourceLanguageName da $targetLanguageName titloù." 168 | "GptTranslateStart" = "Kregiñ zo bet graet gant an droidigezh GPT a $sourceLanguageName da $targetLanguageName titloù." 169 | "TranslationFinished" = "Troidigezh an titloù echu eo." 170 | "FailedToParseLanguages" = "C'hwitadennañ war an danvez yezhoù orin ha palez" 171 | "SubtitlesMissing" = "Titloù zo enlañket pe zo koll - ne c'haller ket sinkronizañ." 172 | "SubtitlesPartiallySynced" = "Titloù sinkronizet, nemet: rann" 173 | } 174 | "ch" = @{ 175 | "SubtitleIssueDetected" = "Problemu gi subtitulos" 176 | "SyncStarted" = "I Sinchroniza $languageName subtitulos na gaigeha." 177 | "SyncFinished" = "$languageName subtitulos siha na gaige gi sinchronize." 178 | "TranslationStarted" = "I Sinchchonza $sourceLanguageName para $targetLanguageName subtitulos na gaigeha." 179 | "GptTranslateStart" = "I Sinchchonza GPT $sourceLanguageName para $targetLanguageName subtitulos na gaigeha." 180 | "TranslationFinished" = "Manmanao i translation gi subtitulos." 181 | "FailedToParseLanguages" = "Kapot I nininiyi gi $sourceLanguageName yan $targetLanguageName" 182 | "SubtitlesMissing" = "Subtitulos gaige gi embedded pat manca – Siña ti sinchronize." 183 | "SubtitlesPartiallySynced" = "Subtitulos siha na gaige gi partial sinchronization: episode" 184 | } 185 | "ce" = @{ 186 | "SubtitleIssueDetected" = "Субтитрта хилар" 187 | "SyncStarted" = "$languageName субтитрта синхронизаран дуьнца." 188 | "SyncFinished" = "$languageName субтитрта синхронизаран оьша." 189 | "TranslationStarted" = "$sourceLanguageName лахьан $targetLanguageName лаьттан субтитрта тарад." 190 | "GptTranslateStart" = "$sourceLanguageName лахьан $targetLanguageName лаьттан GPT-да субтитрта тарад." 191 | "TranslationFinished" = "Субтитрта таржам оьша." 192 | "FailedToParseLanguages" = "Т1еьхьахьарали хьажор наьшх лахарни т1ехьарсар дуьцат1о." 193 | "SubtitlesMissing" = "Субтитрта хадона, хилара – синхронизарийн дуьцеташ бу." 194 | "SubtitlesPartiallySynced" = "Субтитрта синхронизарян т1айпе хилаьжара дуьцанара: episode" 195 | } 196 | "ny" = @{ 197 | "SubtitleIssueDetected" = "Zovuta pa subtitles" 198 | "SyncStarted" = "Kuyamba kwa sync ya $languageName ma subtitles." 199 | "SyncFinished" = "$languageName ma subtitles akwaniritsidwa." 200 | "TranslationStarted" = "Chiyambi cha kumasulira kwa $sourceLanguageName kupita $targetLanguageName ma subtitles." 201 | "GptTranslateStart" = "Chiyambi cha kumasulira kwa $sourceLanguageName kupita $targetLanguageName ma subtitles pogwiritsa ntchito GPT." 202 | 203 | "TranslationFinished" = "Kumasulira kwa subtitles kwatha." 204 | "FailedToParseLanguages" = "Kulephera kutanthauzira chinenero choyambira ndi chinenero cholingana" 205 | "SubtitlesMissing" = "Ma subtitles akuphatikizidwa kapena kusowa - sangathe kugwirizanitsidwa." 206 | "SubtitlesPartiallySynced" = "Ma subtitles agwirizanitsidwa koma pang'ono: episode" 207 | } 208 | "cv" = @{ 209 | "SubtitleIssueDetected" = "Çулислă çихтĕн" 210 | "SyncStarted" = "$languageName çулислă сĕнчен вăхăтпа çинхронланать." 211 | "SyncFinished" = "$languageName çулислă çинхронланать." 212 | "TranslationStarted" = "$sourceLanguageName тăсăртан $targetLanguageName çулислă тарихни." 213 | "GptTranslateStart" = "$sourceLanguageName тăсăртан $targetLanguageName çулислă GPT пурăн тарихни." 214 | "TranslationFinished" = "Çулислă тарихланать." 215 | "FailedToParseLanguages" = "Çырать $sourceLanguageName-ро $targetLanguageName не зачетать." 216 | "SubtitlesMissing" = "Çулислă хала чухӑнăма пулăт - теç çинхронларĕ." 217 | "SubtitlesPartiallySynced" = "Çулислă çинхронланать аннан чунлатăн: эпизод" 218 | } 219 | "kw" = @{ 220 | "SubtitleIssueDetected" = "Teusans drehevel subtitles" 221 | "SyncStarted" = "$languageName subtitles yw syncronised." 222 | "SyncFinished" = "$languageName subtitles a syncronised." 223 | "TranslationStarted" = "$sourceLanguageName a syncronised der $targetLanguageName subtitles." 224 | "GptTranslateStart" = "$sourceLanguageName a syncronised der $targetLanguageName subtitles dre GPT." 225 | "TranslationFinished" = "Subtitles yw wosa tevethys." 226 | "FailedToParseLanguages" = "Kuspary dreineans source ha gweltek languages." 227 | "SubtitlesMissing" = "Subtitles yw embedys ynno, pe fawyk - ny a all syncronised." 228 | "SubtitlesPartiallySynced" = "Subtitles yw syncronised an peth namyn: episode" 229 | } 230 | "co" = @{ 231 | "SubtitleIssueDetected" = "Problema di sottotitoli" 232 | "SyncStarted" = "Syncing di sottotitoli $languageName iniziato." 233 | "SyncFinished" = "I sottotitoli $languageName sò stati sincronizati." 234 | "TranslationStarted" = "A traduzzione di $sourceLanguageName in $targetLanguageName sottotituli hà iniziatu." 235 | "GptTranslateStart" = "A traduzzione di $sourceLanguageName in $targetLanguageName sottotituli hà iniziatu cù GPT." 236 | "TranslationFinished" = "A traduzzione di sottotituli hè finita." 237 | "FailedToParseLanguages" = "Mancu capisce e lingue di origine è di destinazione" 238 | "SubtitlesMissing" = "I sottotituli sò incorporati o mancanu - ùn ponu micca sincronizà." 239 | "SubtitlesPartiallySynced" = "Sottotituli sincronizati cù l'eccezzioni di: episodiu" 240 | } 241 | "cr" = @{ 242 | "SubtitleIssueDetected" = "ᐱᑳᓇᐁᐧ ᒧᐧᐢᑳᐠ" 243 | "SyncStarted" = "$languageName ᐱᑳᓇᐁᐧᐠ ᐃᑭᔭᐦᐄᑭᓇᐁᐧᑯᐃᐧᓂᐤ" 244 | "SyncFinished" = "$languageName ᐃᑭᔭᐦᐄᑭᓇᐁᐧᐠ ᐊᑭᒋᒥᐢᑯᓇᒣᐠ" 245 | "TranslationStarted" = "$sourceLanguageName ᐃᑭᔭᐦᐄᑭᓇᐁᐧ ᐱᑳᓇᐁᐧᐠ ᐅᐱᓀᓱᓂᓂᑯᐃᐧ $targetLanguageName" 246 | "GptTranslateStart" = "$sourceLanguageName ᐃᑭᔭᐦᐄᑭᓇᐁᐧ ᐱᑳᓇᐁᐧᐠ ᐅᐱᓀᓱᓂᓂᑯᐃᐧ $targetLanguageName ᑯᒪ GPT" 247 | "TranslationFinished" = "ᐊᒋᒥᐢᑯᓇᒣᐠ" 248 | "FailedToParseLanguages" = "ᑌᐦᑯ ᐋᐦᑲᓇᐁᐧ ᑎᒧᐁᒣᓇᐁᐧᒣᐧᑎᓂᐧᐃᐧᓂᐤ" 249 | "SubtitlesMissing" = "ᐱᑳᓇᐁᐧᐠ ᐋᒋᐧᔭᓯᐠ" 250 | "SubtitlesPartiallySynced" = "ᐱᑳᓇᐁᐧᐠ ᐅᐢᑌᒋᑯᑎᔥ ᐋᔅᑭᓂᐢᑎᐃᐧᐃᐧᔭᑯᓇᒣᐠ" 251 | } 252 | } 253 | 254 | if ($translations.ContainsKey($language) -and $translations[$language].ContainsKey($key)) { 255 | return $translations[$language][$key] 256 | } else { 257 | return $translations["en"][$key] # Fallback to English if translation is not found 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /functions/Trigger-Kometa.ps1: -------------------------------------------------------------------------------- 1 | function Trigger-Kometa { 2 | Log-Message -Type "INF" -Message "Applying Kometa overlays. Please wait..." 3 | 4 | $dockerArgs = @( 5 | "run", 6 | "--rm", 7 | "-v", $kometaConfig, 8 | "kometateam/kometa", 9 | "--run" 10 | ) 11 | 12 | # Create separate temporary files for output and error logs 13 | $stdoutFile = New-TemporaryFile 14 | $stderrFile = New-TemporaryFile 15 | 16 | try { 17 | Start-Process -FilePath "docker" -ArgumentList $dockerArgs -NoNewWindow -Wait -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile 18 | Log-Message -Type "SUC" -Message "Kometa overlays applied successfully." 19 | } catch { 20 | Log-Message -Type "ERR" -Message "Error executing Kometa: $_" 21 | } finally { 22 | # Cleanup temp files 23 | Remove-Item -Path $stdoutFile -Force -ErrorAction SilentlyContinue 24 | Remove-Item -Path $stderrFile -Force -ErrorAction SilentlyContinue 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /overr-syncerr-main.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = "Stop" 2 | trap { 3 | Log-Message -Type "ERR" -Message "Unhandled error: $_" 4 | exit 1 5 | } 6 | 7 | # Import Functions 8 | Get-ChildItem -Path './functions/*.ps1' | ForEach-Object { . $_.FullName } 9 | 10 | # Parse and set up environment variables 11 | $requestIntervalCheck = $env:CHECK_REQUEST_INTERVAL 12 | $maintainerrUrl = $env:MAINTAINERR_URL 13 | $audioTrackFilter = $env:AUDIO_TRACK_FILTER 14 | $maintainerrApiKey = $env:MAINTAINERR_API_KEY 15 | $unwatchedLimit = [int]$env:UNWATCHED_LIMIT 16 | $movieQuotaLimit = [int]$env:MOVIE_QUOTA_LIMIT 17 | $movieQuotaDays = [int]$env:MOVIE_QUOTA_DAYS 18 | $tvQuotaLimit = [int]$env:TV_QUOTA_LIMIT 19 | $tvQuotaDays = [int]$env:TV_QUOTA_DAYS 20 | $bazarrApiKey = $env:BAZARR_API_KEY 21 | $bazarrUrl = $env:BAZARR_URL 22 | $radarrApiKey = $env:RADARR_API_KEY 23 | $radarrUrl = $env:RADARR_URL 24 | $sonarrApiKey = $env:SONARR_API_KEY 25 | $sonarrUrl = $env:SONARR_URL 26 | $bazarr4kApiKey = $env:BAZARR_4K_API_KEY 27 | $bazarr4kUrl = $env:BAZARR_4K_URL 28 | $radarr4kApiKey = $env:RADARR_4K_API_KEY 29 | $radarr4kUrl = $env:RADARR_4K_URL 30 | $sonarr4kApiKey = $env:SONARR_4K_API_KEY 31 | $sonarr4kUrl = $env:SONARR_4K_URL 32 | $overseerrApiKey = $env:OVERSEERR_API_KEY 33 | $overseerrUrl = $env:OVERSEERR_URL 34 | $plexClientId = $env:SERVER_CLIENTID 35 | $kometaConfig = $env:KOMETA_CONFIG_PATH 36 | $plexToken = $env:PLEX_TOKEN 37 | $plexHost = $env:PLEX_HOST 38 | $animeLibraryNames = $env:ANIME_LIBRARY_NAME -split "," 39 | $moviesLibraryNames = $env:MOVIES_LIBRARY_NAME -split "," 40 | $seriesLibraryNames = $env:SERIES_LIBRARY_NAME -split "," 41 | $port = $env:PORT 42 | $enableMediaAvailableHandling = $env:ENABLE_MEDIA_AVAILABLE_HANDLING -eq "true" 43 | $queue = [System.Collections.Queue]::new() 44 | $openAiApiKey = $env:OPEN_AI_API_KEY 45 | $useGPT = $env:ENABLE_GPT -eq "true" 46 | $enableKometa = $env:ENABLE_KOMETA -eq "true" 47 | $enableAudioPref = $env:ENABLE_AUDIO_PREF -eq "true" 48 | $enableSonarrEpisodeHandler = $env:SONARR_EP_TRACKING -eq "true" 49 | $modelGPT = $env:MODEL_GPT 50 | $maxTokens = [int]$env:MAX_TOKENS 51 | $addLabelKeywords = $env:ADD_LABEL_KEYWORDS 52 | $languageMapJson = $env:LANGUAGE_MAP 53 | $syncKeywordsJson = $env:SYNC_KEYWORDS 54 | 55 | # Determine Plex availability 56 | $enablePlex = -not ([string]::IsNullOrWhiteSpace($plexHost) -or [string]::IsNullOrWhiteSpace($plexToken)) 57 | if (-not $enablePlex) { 58 | Log-Message -Type "WRN" -Message "Plex host or token is not configured. User labels, subtitle, and audio preferences will be DISABLED." 59 | } 60 | 61 | # Default port fallback 62 | if ([string]::IsNullOrWhiteSpace($port)) { 63 | $port = 8089 64 | Log-Message -Type "WRN" -Message "PORT environment variable is not set. Defaulting to port 8089." 65 | } elseif (-not ($port -as [int])) { 66 | $port = 8089 67 | Log-Message -Type "WRN" -Message "PORT environment variable is invalid. Defaulting to port 8089." 68 | } 69 | 70 | # Generate user tokens and preferences 71 | if ($enablePlex -and $enableAudioPref) { 72 | Log-Message -Type "INF" -Message "Fetching Plex user tokens..." 73 | Get-PlexUserTokens -plexToken $plexToken -plexClientId $plexClientId 74 | 75 | Log-Message -Type "INF" -Message "Generating user subtitle preferences..." 76 | Generate-UserSubtitlePreferences 77 | 78 | Log-Message -Type "INF" -Message "Generating user audio preferences..." 79 | Generate-UserAudioPreferences 80 | } elseif (-not $enablePlex) { 81 | Log-Message -Type "WRN" -Message "Plex is not enabled. Skipping user subtitle/audio preferences." 82 | } else { 83 | Log-Message -Type "WRN" -Message "Fetching Plex user tokens and generating preferences is DISABLED." 84 | } 85 | 86 | # Parse JSON inputs 87 | try { 88 | $languageMapPSObject = ConvertFrom-Json -InputObject $languageMapJson 89 | $languageMap = @{} 90 | $languageMapPSObject.psobject.Properties | ForEach-Object { 91 | $languageMap.Add($_.Name, $_.Value) 92 | } 93 | Log-Message -Type "SUC" -Message "Language map parsed successfully" 94 | } catch { 95 | $languageMap = @{} 96 | Log-Message -Type "ERR" -Message "Error parsing language map: $_" 97 | } 98 | 99 | try { 100 | $syncKeywords = ConvertFrom-Json -InputObject $syncKeywordsJson 101 | Log-Message -Type "SUC" -Message "Sync keywords parsed successfully" 102 | } catch { 103 | $syncKeywords = @('sync', 'out of sync', 'synchronize', 'synchronization') 104 | Log-Message -Type "ERR" -Message "Error parsing sync keywords: $_" 105 | } 106 | 107 | # Organize libraries 108 | $libraryCategories = @{ 109 | "Movies" = $moviesLibraryNames 110 | "TV" = $seriesLibraryNames 111 | "Anime" = $animeLibraryNames 112 | } 113 | 114 | # Fetch section IDs 115 | if ($enablePlex) { 116 | $libraryIds = Get-PlexLibraryIds -plexHost $plexHost -plexToken $plexToken -libraryCategories $libraryCategories 117 | $moviesSectionIds = $libraryIds["Movies"] 118 | $seriesSectionIds = $libraryIds["TV"] 119 | $animeSectionIds = $libraryIds["Anime"] 120 | 121 | Log-Message -Type "SUC" -Message "Movies Section IDs: $moviesSectionIds" 122 | Log-Message -Type "SUC" -Message "Series Section IDs: $seriesSectionIds" 123 | Log-Message -Type "SUC" -Message "Anime Section IDs: $animeSectionIds" 124 | } else { 125 | Log-Message -Type "WRN" -Message "Skipping Plex section ID fetch due to missing configuration." 126 | } 127 | 128 | # Check if monitoring is enabled 129 | $monitorRequests = $env:MONITOR_REQUESTS -eq "true" 130 | $collectionsInterval = [int]$env:COLLECTIONS_INTERVAL 131 | 132 | if ($monitorRequests -and $enablePlex) { 133 | Start-OverseerrRequestMonitor -overseerrUrl $overseerrUrl ` 134 | -plexHost $plexHost ` 135 | -plexToken $plexToken ` 136 | -overseerrApiKey $overseerrApiKey 137 | Log-Message -Type "INF" -Message "Started Overseerr request monitor." 138 | } elseif (-not $enablePlex) { 139 | Log-Message -Type "WRN" -Message "Request monitoring skipped: Plex is not enabled." 140 | } else { 141 | Log-Message -Type "WRN" -Message "Request monitoring is not enabled. Skipping Overseerr request monitor." 142 | } 143 | 144 | # Feature info logs 145 | Log-Message -Type "INF" -Message "GPT is $($useGPT ? "used" : "not used") for subtitle translation." 146 | Log-Message -Type ($enableKometa ? "INF" : "WRN") -Message "Kometa is $($enableKometa ? "ENABLED" : "DISABLED")." 147 | Log-Message -Type ($enableAudioPref ? "INF" : "WRN") -Message "Audio preference is $($enableAudioPref ? "ENABLED" : "DISABLED")." 148 | 149 | function Parse-IncomingPayload { 150 | param ( 151 | [System.Net.HttpListenerRequest]$Request 152 | ) 153 | 154 | $reader = [System.IO.StreamReader]::new($Request.InputStream) 155 | $rawBody = $reader.ReadToEnd() 156 | $reader.Close() 157 | 158 | if ($Request.ContentType -and $Request.ContentType -like "*multipart/form-data*") { 159 | if ($rawBody -match 'name="payload"\s+Content-Type:\s*application/json\s+(?{.*})\s+--') { 160 | $jsonRaw = $matches['json'] 161 | return $jsonRaw 162 | } else { 163 | Log-Message -Type "ERR" -Message "Could not parse JSON from multipart payload" 164 | return $null 165 | } 166 | } 167 | 168 | return $rawBody 169 | } 170 | 171 | # HTTP listener block 172 | try { 173 | $listener = [System.Net.HttpListener]::new() 174 | $listener.Prefixes.Add("http://*:$port/") 175 | $listener.Start() 176 | Log-Message -Type "INF" -Message "Listening for webhooks on http://localhost:$port/" 177 | 178 | while ($true) { 179 | $context = $listener.GetContext() 180 | $request = $context.Request 181 | $response = $context.Response 182 | 183 | if ($request.HttpMethod -eq "POST") { 184 | $jsonPayload = Parse-IncomingPayload -Request $request 185 | 186 | if ($null -ne $jsonPayload) { 187 | Enqueue-Payload -Payload $jsonPayload 188 | } else { 189 | Log-Message -Type "ERR" -Message "Failed to extract payload from request." 190 | } 191 | 192 | Process-Queue 193 | 194 | $response.StatusCode = 200 195 | $response.StatusDescription = "OK" 196 | $response.Close() 197 | } else { 198 | $response.StatusCode = 405 199 | $response.StatusDescription = "Method Not Allowed" 200 | $response.Close() 201 | } 202 | } 203 | } catch { 204 | Log-Message -Type "ERR" -Message "HTTP listener error: $_" 205 | } finally { 206 | if ($listener) { 207 | $listener.Stop() 208 | Log-Message -Type "WRN" -Message "HTTP listener stopped unexpectedly." 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /previews/movies.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gssariev/overr-syncerr/69269eb5bdd07344cefe6f5f02c6480a526bb7a0/previews/movies.gif -------------------------------------------------------------------------------- /previews/script_log.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gssariev/overr-syncerr/69269eb5bdd07344cefe6f5f02c6480a526bb7a0/previews/script_log.png -------------------------------------------------------------------------------- /previews/series.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gssariev/overr-syncerr/69269eb5bdd07344cefe6f5f02c6480a526bb7a0/previews/series.gif --------------------------------------------------------------------------------