├── .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 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
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 |
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
--------------------------------------------------------------------------------