Guest User

Dual Audio Release Creating

a guest
Oct 4th, 2026
48
0
7 days
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Bash 42.54 KB | None | 0 0
  1. #!/bin/bash
  2. # This script checks a file for whether it has both german and english audio.
  3. # If one's missing. it makes radarr grab a new release to extract the audio from
  4. # and inserts it into the original file.
  5. # It also synchronizes the inserted audio track with the original audio track
  6. # using their audio energy profiles
  7. #
  8. # Usage: ./createSuperRelease_radarr.sh /path/to/file.mkv
  9.  
  10. set -euo pipefail
  11. main(){
  12.   ###############################################################################################################
  13.   #################################### Modify following Variables to your liking ################################
  14.   ###############################################################################################################
  15.  
  16.   RADARR_API_KEY="secretkey" # your radarr API key
  17.   RADARR_URL="http://127.0.0.1:7878" # your local radarr URL
  18.   RADARR_DB_PATH="/opt/radarr/radarr.db" # absolute host path to radarr database
  19.  
  20.   SABNZBD_URL="http://127.0.0.1:8080" # your local SABnzbd URL
  21.   SABNZBD_API_KEY="secretkey" # your SABnzbd API key
  22.  
  23.   WORKING_DIR="/temp/transcode_cache/" # work directory for merging audio streams and creating new end file
  24.   PERMS_GROUP="root:root" # file permission user:group for resulting file
  25.   PERMS_BITS="755" # file permission bits user|group|other
  26.  
  27.   # uncomment this section and execute it to retrieve the quality profile names and IDs from your radarr database
  28.   # sqlite3 -noheader -batch $RADARR_DB_PATH \
  29.   #   "SELECT Id,Name FROM QualityProfiles"
  30.   # exit 0
  31.   QualityProfileId_ger=21 # one profile is needed for first language and another for second language, you gotta create these QualityProfiles yourself and retrieve the internal IDs
  32.   QualityProfileId_eng=20
  33.  
  34.   ###############################################################################################################
  35.   #################################### Modify above Variables to your liking ####################################
  36.   ###############################################################################################################
  37.  
  38.   FILE_PATH="$1"
  39.  
  40.   set +H
  41.   START_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
  42.   echo "$START_TIME IS START_TIME"
  43.  
  44.   ENGLISH_ID=1 # internal radarr Language IDs, do not change unless you know what you're doing
  45.   GERMAN_ID=4
  46.  
  47.   if [[ ! -f "$FILE_PATH" ]]; then
  48.     echo "Input File doesn't exist"
  49.     exit 1
  50.   fi
  51.  
  52.  
  53.   ### Retrieve file path and trim to Short Path ###
  54.   echo "Processing: $FILE_PATH"
  55.   REL_PATH="$(basename "$FILE_PATH")"
  56.   echo "Handling file: $REL_PATH"
  57.  
  58.   ### Retrieve unique movie file ID and Release Group ###
  59.   IFS='|' read -r MOVIE_FILE_ID RELEASE_GROUP MOVIE_ID < <(
  60.       sqlite3 -noheader -batch $RADARR_DB_PATH \
  61.       "SELECT Id,ReleaseGroup,MovieId FROM MovieFiles WHERE RelativePath = '$REL_PATH'"
  62.   )
  63.   echo "Found MOVIE FILE ID: $MOVIE_FILE_ID AND Release Group: $RELEASE_GROUP and Movie ID: $MOVIE_ID"
  64.  
  65.   ### check whether file has english audio according to radarr's ffprobe scan ###
  66.   HAS_ENGLISH=$(sqlite3 -noheader -batch $RADARR_DB_PATH "
  67.  SELECT EXISTS (
  68.    SELECT 1
  69.    FROM MovieFiles,
  70.        json_each(MovieFiles.Languages)
  71.    WHERE MovieFiles.Id = $MOVIE_FILE_ID
  72.      AND json_each.value = $ENGLISH_ID
  73.  );
  74.  ")
  75.  
  76.   ### check whether file has german audio according to radarr's ffprobe scan ###
  77.   HAS_GERMAN=$(sqlite3 -noheader -batch $RADARR_DB_PATH "
  78.  SELECT EXISTS (
  79.    SELECT 1
  80.    FROM MovieFiles,
  81.        json_each(MovieFiles.Languages)
  82.    WHERE MovieFiles.Id = $MOVIE_FILE_ID
  83.      AND json_each.value = $GERMAN_ID
  84.  );
  85.  ")
  86.  
  87.   ### only process files that either have german or english audio stream, not both or neither ###
  88.   if [[ "$HAS_ENGLISH" -eq 1 && "$HAS_GERMAN" -eq 1 ]]; then
  89.       echo "German and English included. Exiting"
  90.       exit 3
  91.   elif [[ "$HAS_ENGLISH" != 1 && "$HAS_GERMAN" != 1 ]]; then
  92.       echo "German and English missing. Exiting"
  93.       exit 3
  94.   elif [[ "$HAS_GERMAN" -eq 1 ]]; then
  95.       echo "English missing"
  96.       MISSING_LANG="$QualityProfileId_eng"
  97.       MISSING_LANG_NAME="eng"
  98.   elif [[ "$HAS_ENGLISH" -eq 1 ]]; then
  99.       echo "German missing"
  100.       MISSING_LANG="$QualityProfileId_ger"
  101.       MISSING_LANG_NAME="ger"
  102.   fi
  103.  
  104.   ### execute ffprobe to check stream properties ###
  105.   echo "Executing ffprobe for check"
  106.   ffprobe "$FILE_PATH"
  107.  
  108.   ### Retrieve current Quality Profile ID ###
  109.   QualityProfileId_original=$(sqlite3 -noheader -batch $RADARR_DB_PATH \
  110.     "SELECT QualityProfileId FROM Movies WHERE Id = $MOVIE_ID")
  111.  
  112.   ### Retrieve new Quality Profile Name ###
  113.   QualityProfileName=$(sqlite3 -noheader -batch $RADARR_DB_PATH \
  114.     "SELECT Name FROM QualityProfiles WHERE Id = $MISSING_LANG")
  115.  
  116.   ### Change Quality Profile to missing language ###
  117.   echo "Temporarily changing Movie QualityProfile to $MISSING_LANG $QualityProfileName"
  118.   sqlite3 -noheader -batch $RADARR_DB_PATH \
  119.     "UPDATE Movies SET QualityProfileId = $MISSING_LANG WHERE Id = $MOVIE_ID"
  120.  
  121.   ### Wait a short moment so the database changes can propagate ###
  122.   sleep 1
  123.  
  124.   ### Use radarr API to start searching for new release that has the missing language ###
  125.   echo "Searching for new release matching missing language"
  126.   RESPONSE=$(curl -s -X POST "$RADARR_URL/api/v3/command" \
  127.     -H "X-Api-Key: $RADARR_API_KEY" \
  128.     -H "Content-Type: application/json" \
  129.     -d "{
  130.          \"name\":\"MoviesSearch\",
  131.          \"movieIds\":[$MOVIE_ID]
  132.        }")
  133.   COMMAND_ID=$(jq -r '.id' <<< "$RESPONSE")
  134.  
  135.   ### Wait a short moment for the radarr search to commence
  136.   sleep 1
  137.  
  138.   ### Retrieve Name of original Quality Profile ###
  139.   echo "Retrieving original QualityProfileName"
  140.   QualityProfileName=$(sqlite3 -noheader -batch $RADARR_DB_PATH \
  141.     "SELECT Name FROM QualityProfiles WHERE Id = $QualityProfileId_original")
  142.  
  143.   ### Change Movie Quality Profile back to original Quality Profile while radarr keeps searching for new release ###
  144.   echo "Changing Movie QualityProfile back to $QualityProfileName"
  145.   sqlite3 -noheader -batch $RADARR_DB_PATH \
  146.     "UPDATE Movies SET QualityProfileId = $QualityProfileId_original WHERE Id = $MOVIE_ID"
  147.  
  148.  
  149.   ### Use radarr API to periodically check whether movie search has found a new release yet ###
  150.   while true; do
  151.       RESPONSE=$(curl -s \
  152.         -H "X-Api-Key: $RADARR_API_KEY" \
  153.         "$RADARR_URL/api/v3/command/$COMMAND_ID")
  154.       echo "$RESPONSE"
  155.  
  156.       STATUS=$(jq -r '.status' <<< "$RESPONSE")
  157.       RESULT=$(jq -r '.result' <<< "$RESPONSE")
  158.       REPORTS=$(jq -r '.message | capture("(?<count>[0-9]+) reports downloaded") | .count' <<< "$RESPONSE")
  159.  
  160.       echo "radarr Command Status: $STATUS"
  161.  
  162.       if [[ "$STATUS" == "completed" ]]; then
  163.           if [[ "$RESULT" != "successful" || "$REPORTS" -eq 0 ]]; then
  164.             echo "No Release found. Exiting"
  165.             exit 4
  166.           fi
  167.           echo "Release found. Reports downloaded: $REPORTS"
  168.           break
  169.       fi
  170.       echo "Release is still being searched"
  171.       echo "Next check in 5 seconds"
  172.       sleep 5
  173.   done
  174.  
  175.  
  176.   ### Use radarr API to periodically check the queue for when the download starts ###
  177.   DOWNLOAD_ID="0"
  178.   RETRY_COUNTER=0 # let's set a limit of 3 retries in case something goes wrong
  179.   while true; do
  180.       RESPONSE=$(curl -s \
  181.         -H "X-Api-Key: $RADARR_API_KEY" \
  182.         "$RADARR_URL/api/v3/queue?page=1&pageSize=100")
  183.  
  184.       echo "$RESPONSE" | jq
  185.  
  186.       RADARR_ENTRY=$(jq -c --argjson id "$MOVIE_ID" \
  187.           '.records[] | select(.movieId == $id)' <<< "$RESPONSE")
  188.  
  189.       if [[ -z $RADARR_ENTRY ]]; then
  190.         ((RETRY_COUNTER+=1))
  191.  
  192.         if [[ $RETRY_COUNTER -ge 3 ]]; then
  193.           echo "No download found after 3 tries. Something's wrong!"
  194.           echo "Exiting Script"
  195.           exit 99
  196.         fi
  197.  
  198.         sleep 5
  199.         continue
  200.       else
  201.         echo "Found Download:"
  202.         echo "$RADARR_ENTRY"
  203.         QUEUE_ID=$(echo "$RADARR_ENTRY" | jq -r '.id')
  204.  
  205.         echo "Found Queue Id: $QUEUE_ID"
  206.         echo "Removing download from radarr Queue so it doesn't get imported by accident. The download still continues on download client"
  207.  
  208.         curl -s -X DELETE \
  209.             -H "X-Api-Key: $RADARR_API_KEY" \
  210.             "$RADARR_URL/api/v3/queue/$QUEUE_ID?removeFromClient=false"
  211.         DOWNLOAD_ID=$(echo "$RADARR_ENTRY" | jq -r '.downloadId')
  212.         echo "Found Download Id: $DOWNLOAD_ID"
  213.  
  214.         break
  215.       fi
  216.   done
  217.  
  218.   ### Use Sabnzbd API to periodically check the queue for when the download is finished ###
  219.   while true; do
  220.       SABNZBD_ENTRY=$(curl -sG "$SABNZBD_URL/api" \
  221.       --data-urlencode "mode=queue" \
  222.       --data-urlencode "output=json" \
  223.       --data-urlencode "apikey=$SABNZBD_API_KEY" \
  224.       --data-urlencode "start=0" \
  225.       --data-urlencode "limit=100" |
  226.       jq --arg id "$DOWNLOAD_ID" '.queue.slots[] | select(.nzo_id == $id)')
  227.  
  228.       if [[ -z $SABNZBD_ENTRY ]]; then # no download found in Sabnzbd Queue after 3 retries or after the download has started
  229.         echo "Download disappeared from queue. Is it completed?"
  230.  
  231.         SABNZBD_ENTRY=$(curl -sG "$SABNZBD_URL/api" \
  232.           --data-urlencode "mode=history" \
  233.           --data-urlencode "output=json" \
  234.           --data-urlencode "apikey=$SABNZBD_API_KEY" \
  235.           --data-urlencode "start=0" \
  236.           --data-urlencode "limit=10" |
  237.           jq --arg id "$DOWNLOAD_ID" '.history.slots[] | select(.nzo_id == $id)')
  238.  
  239.         if [[ ! -z "$SABNZBD_ENTRY" ]]; then # found download in Sabnzbd download history
  240.           echo "Found History Entry"
  241.           echo "$SABNZBD_ENTRY"
  242.  
  243.           STATUS=$(jq -r '.status' <<< "$SABNZBD_ENTRY")
  244.  
  245.           if [[ $STATUS == "Completed" ]]; then # download is completed
  246.             echo "Download is completed"
  247.             STORAGE_PATH=$(jq -r '.storage' <<< "$SABNZBD_ENTRY")
  248.             if [[ -d "$STORAGE_PATH" ]]; then
  249.                 OUTPUT_PATH="$STORAGE_PATH"
  250.             elif [[ -f "$STORAGE_PATH" ]]; then
  251.                 OUTPUT_PATH=$(dirname "$STORAGE_PATH")
  252.             fi
  253.             echo "Found Download Path $OUTPUT_PATH"
  254.             break
  255.           elif [[ $STATUS == "Failed" ]]; then # download failed
  256.             echo "Download failed. Blacklisting release"
  257.  
  258.             HISTORY=$(curl -s \ # retieve radarr History for this specific release
  259.               -H "X-Api-Key: $RADARR_API_KEY" \
  260.               "$RADARR_URL/api/v3/history?downloadid=$DOWNLOAD_ID&pageSize=50")
  261.  
  262.             # retrieve history ID
  263.             echo "Retrieving History ID of Download"
  264.             echo "$HISTORY"
  265.             FAILED_HISTORY_ID=$(echo "$HISTORY" | jq -r ' # only grab latest history event
  266.              .records
  267.              | map(select(.eventType == "grabbed"))
  268.              | sort_by(.date)
  269.              | reverse
  270.              | .[0].id
  271.            ')
  272.  
  273.             # mark release as failed in radarr
  274.             echo "Marking release as failed: $FAILED_HISTORY_ID"
  275.             curl -s -X POST \
  276.               "$RADARR_URL/api/v3/history/failed/$FAILED_HISTORY_ID" \
  277.               -H "X-Api-Key: $RADARR_API_KEY" \
  278.               > /dev/null
  279.             echo "Re-Running Script in 5 seconds to search for a new release"
  280.             sleep 5
  281.             exec "$0" "$@"
  282.           else # download is neither failed nor completed, probably post processing
  283.             ACTION_LINE=$(jq -r '.action_line' <<< "$SABNZBD_ENTRY")
  284.             echo "Waiting for post processing: $ACTION_LINE"
  285.             echo "Next check in 10 seconds"
  286.             sleep 10
  287.             continue
  288.           fi
  289.         else # found no download in Sabnzbd queue nor download history, so where is it? Maybe misconfigured download client
  290.           echo "Found no history entry. Something's wrong!"
  291.           echo "Exiting Script"
  292.           exit 99
  293.         fi
  294.       fi
  295.  
  296.       echo "Found Sabnzbd Download" # regular download status check
  297.       echo "$SABNZBD_ENTRY"
  298.  
  299.       DOWNLOAD_STATUS=$(echo "$SABNZBD_ENTRY" | jq -r '.status')
  300.  
  301.       if [[ true ]]; then # "$DOWNLOAD_STATUS" == "Downloading"
  302.           echo "Sabnzbd download is ongoing: $DOWNLOAD_STATUS"
  303.           TIMELEFT=$(echo "$SABNZBD_ENTRY" | jq -r '.timeleft')
  304.  
  305.           if [[ "$TIMELEFT" =~ ^[0-9]+:[0-9]{2}:[0-9]{2}$ ]]; then
  306.               IFS=: read -r HOURS MINUTES SECONDS <<< "$TIMELEFT"
  307.               SLEEP_TIME=$((10#$HOURS * 3600 + 10#$MINUTES * 60 + 10#$SECONDS))
  308.           else
  309.               SLEEP_TIME=10
  310.           fi
  311.  
  312.           if (( SLEEP_TIME > 30 )); then
  313.               SLEEP_TIME=$((SLEEP_TIME / 2))
  314.           elif (( SLEEP_TIME < 10 )); then
  315.               SLEEP_TIME=10
  316.           fi
  317.       fi
  318.       echo "Next Check in $SLEEP_TIME seconds"
  319.       sleep "$SLEEP_TIME"
  320.   done
  321.   echo "radarr download is done. Downloaded file is in $OUTPUT_PATH"
  322.  
  323.   ### Find video file in newly downloaded file path in case it has more than one video file ###
  324.   echo "Searching for matching episode file"
  325.   AUDIO_SOURCE=""
  326.   mapfile -d '' VIDEO_FILES < <(find "$OUTPUT_PATH" -type f \( \
  327.       -iname "*.mkv" -o \
  328.       -iname "*.mp4" -o \
  329.       -iname "*.avi" -o \
  330.       -iname "*.mov" -o \
  331.       -iname "*.webm" -o \
  332.       -iname "*.m4v" -o \
  333.       -iname "*.ts" \
  334.       \) -print0)
  335.  
  336.   if [[ "${#VIDEO_FILES[@]}" -eq 0 ]]; then
  337.       echo "No video file found."
  338.       exit 11
  339.   elif [[ "${#VIDEO_FILES[@]}" -eq 1 ]]; then
  340.       AUDIO_SOURCE="${VIDEO_FILES[0]}"
  341.       echo "Found matching file $AUDIO_SOURCE"
  342.   else
  343.       echo "Found multiple video files in Download Path. No clue which one's the right one."
  344.       echo "Exiting script"
  345.       exit 13
  346.   fi
  347.  
  348.  
  349.   ### Checking whether new file actually has wanted language ###
  350.   echo "Checking downloaded file for whether it has wanted audio language"
  351.   if [[ "$MISSING_LANG_NAME" == "ger" ]]; then
  352.       AUDIO_LANG="de|deu|ger"
  353.   elif [[ "$MISSING_LANG_NAME" == "eng" ]]; then
  354.       AUDIO_LANG="en|eng"
  355.   else
  356.       echo "Invalid language: $LANG"
  357.       exit 99
  358.   fi
  359.  
  360.   if ffprobe -v error \
  361.       -select_streams a \
  362.       -show_entries stream_tags=language \
  363.       -of csv=p=0 \
  364.       "$AUDIO_SOURCE" | grep -Eqi "^$AUDIO_LANG$"; then
  365.       echo "File has wanted audio language"
  366.   else ### If File doesn't have the wanted audio language, check for the other language. Although we don\'t want it, we can blacklist this release if it doesn\'t have any viable language (here: neither german or english) ###
  367.       if [[ "$AUDIO_LANG" == "en" ]]; then
  368.         AUDIO_LANG="de|deu|ger"
  369.       else
  370.         AUDIO_LANG="en|eng"
  371.       fi
  372.       echo "File doesn't have wanted audio language"
  373.       if ! ffprobe -v error \
  374.           -select_streams a \
  375.           -show_entries stream_tags=language \
  376.           -of csv=p=0 \
  377.           "$AUDIO_SOURCE" | grep -Eqi "^$AUDIO_LANG$";
  378.       then
  379.           echo "File also doesn't have other viable language. Blacklisting release..."
  380.  
  381.           HISTORY=$(curl -s \
  382.             -H "X-Api-Key: $RADARR_API_KEY" \
  383.             "$RADARR_URL/api/v3/history?downloadid=$DOWNLOAD_ID&pageSize=50")
  384.  
  385.           # retrieve history ID
  386.           echo "Retrieving History ID of Download"
  387.           echo "$HISTORY"
  388.           FAILED_HISTORY_ID=$(echo "$HISTORY" | jq -r '
  389.            .records
  390.            | map(select(.eventType == "grabbed"))
  391.            | sort_by(.date)
  392.            | reverse
  393.            | .[0].id
  394.          ')
  395.  
  396.           # mark release as failed
  397.           echo "Marking release as failed: $FAILED_HISTORY_ID"
  398.           curl -s -X POST \
  399.             "$RADARR_URL/api/v3/history/failed/$FAILED_HISTORY_ID" \
  400.             -H "X-Api-Key: $RADARR_API_KEY" \
  401.             > /dev/null
  402.  
  403.           echo "Removing downloaded file"
  404.           rm -r "$OUTPUT_PATH"
  405.       fi
  406.  
  407.       echo "Re-Running Script"
  408.       sleep 5
  409.       exec "$0" "$@"
  410.       exit 99
  411.   fi
  412.  
  413.   ### Move Found Video File to work Directory ###
  414.   extension="${AUDIO_SOURCE##*.}"
  415.   AUDIO_FILE_PATH="${WORKING_DIR}${MOVIE_ID}_audio.${extension}"
  416.  
  417.   echo "Moving file to work directory $WORKING_DIR"
  418.   mv "$AUDIO_SOURCE" "$AUDIO_FILE_PATH"
  419.   #cp "$AUDIO_SOURCE" "$AUDIO_FILE_PATH"
  420.  
  421.   ### Delete file from download directory ###
  422.   echo "Deleting file from download directory"
  423.   rm -r "$OUTPUT_PATH"
  424.  
  425.   ### Copy Original File to work Directory ###
  426.   echo "Copying original file to work directory"
  427.   cp "$FILE_PATH" "$WORKING_DIR"
  428.  
  429.   ### Retrieve absolute file path in work directory ###
  430.   FILE_PATH_BASE="$(basename "$FILE_PATH")"
  431.   BASE_FILE_PATH="${WORKING_DIR}$FILE_PATH_BASE"
  432.   OUTPUT_FILE_PATH="${WORKING_DIR}output.mkv"
  433.  
  434.  
  435.   ### Start executing python script to calculate audio stream offset and synchronize audio streams of original file and new file ###
  436.   echo "Executing command: process_audio $BASE_FILE_PATH $AUDIO_FILE_PATH  $OUTPUT_FILE_PATH $MISSING_LANG_NAME"
  437.   process_audio "$BASE_FILE_PATH" "$AUDIO_FILE_PATH" "$OUTPUT_FILE_PATH" "$MISSING_LANG_NAME"
  438.  
  439.   EXIT_CODE=$?
  440.  
  441.   if [[ $EXIT_CODE -ge 1 ]]; then
  442.       echo "Merging Audio Streams failed in python script. Exiting Script."
  443.       exit 20
  444.   fi
  445.  
  446.  
  447.   ### Delete work files to only keep file with merged audio streams ###
  448.   echo "Audios have been merged. Deleting work files..."
  449.   rm "$BASE_FILE_PATH"
  450.   rm "$AUDIO_FILE_PATH"
  451.  
  452.   if [[ ! -f "$OUTPUT_FILE_PATH" ]]; then
  453.     echo "Merging Audio Streams failed for some reason. Exiting Script."
  454.     exit 21
  455.   fi
  456.  
  457.   echo "Adjusting permissions of resulting file so radarr can access it"
  458.   chown $PERMS_GROUP "$OUTPUT_FILE_PATH"
  459.   chmod $PERMS_BITS "$OUTPUT_FILE_PATH"
  460.  
  461.   echo "Renaming resulting file to original file name"
  462.   mv "$OUTPUT_FILE_PATH" "$BASE_FILE_PATH"
  463.  
  464.  
  465.   echo "Deleting original file in order to replace it"
  466.   curl -X DELETE \
  467.     "$RADARR_URL/api/v3/moviefile/$MOVIE_FILE_ID" \
  468.     -H "X-Api-Key: $RADARR_API_KEY"
  469.  
  470.   sleep 2
  471.  
  472.   echo "Calling radarr API to import resulting file"
  473.   RESPONSE=$(curl -X POST \
  474.     -H "X-Api-Key: $RADARR_API_KEY" \
  475.     -H "Content-Type: application/json" \
  476.     "$RADARR_URL/api/v3/command" \
  477.     -d '{
  478.      "name": "DownloadedMoviesScan",
  479.      "path": "'"$BASE_FILE_PATH"'"
  480.    }')
  481.   COMMAND_ID=$(jq -r '.id' <<< "$RESPONSE")
  482.  
  483.   while true; do
  484.       RESPONSE=$(curl -s \
  485.         -H "X-Api-Key: $RADARR_API_KEY" \
  486.         "$RADARR_URL/api/v3/command/$COMMAND_ID")
  487.       echo "$RESPONSE"
  488.  
  489.       STATUS=$(jq -r '.status' <<< "$RESPONSE")
  490.       RESULT=$(jq -r '.result' <<< "$RESPONSE")
  491.  
  492.       echo "radarr Command Status: $STATUS"
  493.  
  494.       if [[ "$STATUS" == "completed" ]]; then
  495.         if [[ $RESULT != "successful" ]]; then
  496.           echo "Import failed for some reason. Exiting Script."
  497.           exit 30
  498.         fi
  499.         echo "File import successful. Continuing..."
  500.         break
  501.       fi
  502.  
  503.       echo "File hasn't been imported yet. Checking again in 5 seconds"
  504.       sleep 5
  505.   done
  506.  
  507.  
  508.   sleep 1
  509.  
  510.   if [[ ! "$RELEASE_GROUP" == "" ]]; then # keep original release group although it's a new file now
  511.     echo "Setting ReleaseGroup of new file to the same of the original file"
  512.     echo "UPDATE MovieFiles SET ReleaseGroup='$RELEASE_GROUP' WHERE MovieId = '$MOVIE_ID'"
  513.     sqlite3 -noheader -batch $RADARR_DB_PATH \
  514.       "UPDATE MovieFiles SET ReleaseGroup='$RELEASE_GROUP' WHERE MovieId = '$MOVIE_ID'"
  515.     sleep 1
  516.   else
  517.     echo "Skipping Release Group Adjustment since it was empty before Script Execution anyway"
  518.   fi
  519.  
  520.   echo "Making radarr rescan movie to retrieve updated Release Group"
  521.   curl -X POST \
  522.     -H "X-Api-Key: $RADARR_API_KEY" \
  523.     -H "Content-Type: application/json" \
  524.     "$RADARR_URL/api/v3/command" \
  525.     -d "{\"name\":\"RefreshMovie\",\"movieId\":$MOVIE_ID}"
  526.  
  527.   echo "Script execution is completed."
  528. }
  529. process_audio() {
  530. python3 - "$1" "$2" "$3" "$4" <<'PY'
  531. """
  532. sync_audio_track.py
  533.  
  534. Sucht in einer zweiten (Quell-)Datei die Audiospur mit einem bestimmten
  535. Sprach-Tag, extrahiert sie nach AAC (44100 Hz), ermittelt per
  536. Energieverlauf-Kreuzkorrelation die zeitliche Verschiebung gegenüber der
  537. Original-Audiospur der ersten (Ziel-)Datei, fügt die neue Spur
  538. zeitlich synchronisiert in eine Kopie der Zieldatei ein und setzt deren
  539. Disposition auf "nicht default", sodass die Original-Tonspur weiterhin
  540. Default bleibt.
  541.  
  542. Aufruf:
  543.     python3 sync_audio_track.py ORIGINAL.mkv ZWEITE_DATEI.mkv OUTPUT.mkv de
  544.  
  545. Abhängigkeiten:
  546.     - ffmpeg / ffprobe im PATH
  547.     - Python-Paket: numpy
  548.  
  549. Ablauf im Detail:
  550.     1. ffprobe auf ZWEITE_DATEI -> passenden Audiostream per Sprach-Tag finden.
  551.     2. Diesen Stream nach AAC/44100 Hz extrahieren (temporäre Datei).
  552.     3. Aus ORIGINAL die erste/Default-Audiospur ebenfalls als Rohsignal
  553.        extrahieren (zum Vergleich, nicht als Output).
  554.     4. Aus beiden Signalen eine grobe Energiehüllkurve (RMS je Zeitfenster)
  555.        berechnen und per Kreuzkorrelation den zeitlichen Versatz (Offset)
  556.        bestimmen.
  557.     5. Je nach Offsetgröße/Richtung:
  558.          - |offset| <= 5s               -> normaler itsoffset-Delay
  559.          - offset > 5s und Delay > 10s  -> Delay einbauen, aber Ende
  560.                                             auf Videolänge kappen
  561.          - offset < -5s (Linksverschiebung
  562.            um mehr als 5s)              -> Anfang der neuen Spur um
  563.                                             |offset| kappen (synchron ab 0),
  564.                                             die dadurch am ENDE fehlende
  565.                                             Zeit (Dauer |offset|) wird mit
  566.                                             dem entsprechenden Ausschnitt
  567.                                             vom ENDE der Original-Audiospur
  568.                                             aufgefüllt, sodass die Gesamt-
  569.                                             länge der Videolänge entspricht
  570.     6. Neue synchronisierte Spur als zusätzlichen Audiostream in eine Kopie
  571.        von ORIGINAL muxen, disposition:a:<neu> auf 0 setzen, Sprach-Tag
  572.        auf das gesuchte Sprach-Tag setzen.
  573. """
  574.  
  575. import argparse
  576. import json
  577. import math
  578. import subprocess
  579. import sys
  580. import tempfile
  581. import os
  582. from pathlib import Path
  583.  
  584. import numpy as np
  585.  
  586. # ---------------------------------------------------------------------------
  587. # Konfiguration
  588. # ---------------------------------------------------------------------------
  589.  
  590. AAC_SAMPLE_RATE = 48000
  591. ANALYSIS_SAMPLE_RATE = 8000       # suffices for energy profile cross correlation, quite quick
  592. WINDOW_MS = 50                    # windows span for energy profile
  593. NORMAL_THRESHOLD_S = 5.0          # threshold for when to fill gap at beginning due to shifting with audio from original audio stream
  594. RIGHT_SHIFT_HARD_LIMIT_S = 10.0   # threshold for when to cut off audio stream at end when shifting it too far to the right
  595. MAX_XCORR_LAG_S = 120.0           # max search span for cross correlation
  596.  
  597.  
  598. def log(msg: str) -> None:
  599.     print(f"[sync_audio_track] {msg}", file=sys.stderr, flush=True)
  600.  
  601.  
  602. def run(cmd, **kwargs):
  603.     log("Executing: " + " ".join(str(c) for c in cmd))
  604.     return subprocess.run(cmd, check=True, **kwargs)
  605.  
  606.  
  607. def run_capture(cmd):
  608.     log("Executing: " + " ".join(str(c) for c in cmd))
  609.     result = subprocess.run(cmd, check=True, capture_output=True, text=True)
  610.     return result.stdout
  611.  
  612.  
  613. # ---------------------------------------------------------------------------
  614. # ffprobe-Hilfsfunktionen
  615. # ---------------------------------------------------------------------------
  616.  
  617. def ffprobe_streams(path: str) -> list:
  618.     out = run_capture([
  619.         "ffprobe", "-v", "error",
  620.         "-print_format", "json",
  621.         "-show_streams",
  622.         str(path),
  623.     ])
  624.     data = json.loads(out)
  625.     return data.get("streams", [])
  626.  
  627.  
  628. def ffprobe_format_duration(path: str) -> float:
  629.     out = run_capture([
  630.         "ffprobe", "-v", "error",
  631.         "-print_format", "json",
  632.         "-show_entries", "format=duration",
  633.         str(path),
  634.     ])
  635.     data = json.loads(out)
  636.     dur = data.get("format", {}).get("duration")
  637.     if dur is None:
  638.         raise RuntimeError(f"Could not evaluate duration of {path}")
  639.     return float(dur)
  640.  
  641.  
  642. def find_audio_stream_by_language(path: str, lang_tag: str) -> dict:
  643.     """
  644.     Sucht in `path` den Audiostream, dessen Sprach-Tag `lang_tag` entspricht.
  645.     Vergleich case-insensitive. Wirft einen Fehler, wenn keiner gefunden wird.
  646.     """
  647.     streams = ffprobe_streams(path)
  648.     audio_streams = [s for s in streams if s.get("codec_type") == "audio"]
  649.  
  650.     lang_tag_lower = lang_tag.strip().lower()
  651.     matches = []
  652.     for s in audio_streams:
  653.         tags = s.get("tags", {}) or {}
  654.         lang = (tags.get("language") or tags.get("LANGUAGE") or "").strip().lower()
  655.         if lang == lang_tag_lower:
  656.             matches.append(s)
  657.  
  658.     if not matches:
  659.         available = [
  660.             (s.get("index"), (s.get("tags", {}) or {}).get("language", "?"))
  661.             for s in audio_streams
  662.         ]
  663.         raise RuntimeError(
  664.             f"No audio stream with '{lang_tag}' found in {path} "
  665.             f"Existing audio streams (index, language): {available}"
  666.         )
  667.  
  668.     if len(matches) > 1:
  669.         log(
  670.             f"WARNING: Multiple audio streams with wanted language tag '{lang_tag}' found "
  671.             f"(indices: {[m.get('index') for m in matches]}). Picking first one."
  672.         )
  673.  
  674.     return matches[0]
  675.  
  676.  
  677. def find_default_or_first_audio_stream(path: str) -> dict:
  678.     """
  679.     Liefert die Default-Audiospur der Datei, oder falls keine als Default
  680.     markiert ist, die erste Audiospur.
  681.     """
  682.     streams = ffprobe_streams(path)
  683.     audio_streams = [s for s in streams if s.get("codec_type") == "audio"]
  684.     if not audio_streams:
  685.         raise RuntimeError(f"No audio stream found in {path}")
  686.  
  687.     for s in audio_streams:
  688.         disp = s.get("disposition", {}) or {}
  689.         if disp.get("default") == 1:
  690.             return s
  691.  
  692.     return audio_streams[0]
  693.  
  694.  
  695. # ---------------------------------------------------------------------------
  696. # Audio-Extraktion
  697. # ---------------------------------------------------------------------------
  698.  
  699. def extract_audio_to_aac(src_path: str, stream_index: int, out_path: str) -> None:
  700.     """
  701.     Extrahiert den Audiostream mit dem absoluten ffmpeg-Streamindex
  702.     `stream_index` aus `src_path` und kodiert ihn nach AAC, 44100 Hz.
  703.     """
  704.     run([
  705.         "ffmpeg", "-y",
  706.         "-i", str(src_path),
  707.         "-map", f"0:{stream_index}",
  708.         "-vn", "-sn", "-dn",
  709.         "-c:a", "aac",
  710.         "-ar", str(AAC_SAMPLE_RATE),
  711.         "-b:a", "192k",
  712.         str(out_path),
  713.     ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  714.  
  715.  
  716. def extract_audio_to_wav_mono(src_path: str, stream_index: int, out_path: str,
  717.                                sample_rate: int = ANALYSIS_SAMPLE_RATE) -> None:
  718.     """
  719.     Extrahiert einen Audiostream als mono PCM-WAV in niedriger Samplerate,
  720.     ausschließlich für die Analyse (Kreuzkorrelation) gedacht.
  721.     """
  722.     run([
  723.         "ffmpeg", "-y",
  724.         "-i", str(src_path),
  725.         "-map", f"0:{stream_index}",
  726.         "-vn", "-sn", "-dn",
  727.         "-ac", "1",
  728.         "-ar", str(sample_rate),
  729.         "-c:a", "pcm_s16le",
  730.         str(out_path),
  731.     ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  732.  
  733.  
  734. # ---------------------------------------------------------------------------
  735. # Energieverlauf & Kreuzkorrelation
  736. # ---------------------------------------------------------------------------
  737.  
  738. def read_wav_mono_pcm16(path: str) -> np.ndarray:
  739.     import wave
  740.     with wave.open(str(path), "rb") as wf:
  741.         n_frames = wf.getnframes()
  742.         raw = wf.readframes(n_frames)
  743.     samples = np.frombuffer(raw, dtype=np.int16).astype(np.float64)
  744.     return samples
  745.  
  746.  
  747. def compute_rms_envelope(samples: np.ndarray, sample_rate: int,
  748.                           window_ms: int = WINDOW_MS) -> np.ndarray:
  749.     """
  750.     Berechnet eine RMS-Energiehüllkurve über nicht überlappende Fenster.
  751.     Ein Wert pro Fenster; Fenstergröße in ms.
  752.     """
  753.     window_size = max(1, int(sample_rate * window_ms / 1000.0))
  754.     n_windows = len(samples) // window_size
  755.     if n_windows == 0:
  756.         return np.array([])
  757.  
  758.     trimmed = samples[: n_windows * window_size]
  759.     reshaped = trimmed.reshape(n_windows, window_size)
  760.     rms = np.sqrt(np.mean(reshaped ** 2, axis=1) + 1e-12)
  761.     return rms
  762.  
  763.  
  764. def normalize_envelope(env: np.ndarray) -> np.ndarray:
  765.     env = env - np.mean(env)
  766.     norm = np.linalg.norm(env)
  767.     if norm > 1e-9:
  768.         env = env / norm
  769.     return env
  770.  
  771.  
  772. def compute_offset_seconds(env_a: np.ndarray, env_b: np.ndarray,
  773.                             window_ms: int = WINDOW_MS,
  774.                             max_lag_s: float = MAX_XCORR_LAG_S) -> float:
  775.     """
  776.     Bestimmt per Kreuzkorrelation den zeitlichen Versatz zwischen
  777.     Referenz-Hüllkurve `env_a` (Original) und `env_b` (neue/eingefügte Spur).
  778.  
  779.     Rückgabewert (offset_seconds):
  780.         > 0  -> env_b liegt zeitlich HINTER env_a
  781.                 (die neue Spur muss nach RECHTS verschoben/verzögert werden,
  782.                  damit sie zur Originalspur passt)
  783.         < 0  -> env_b liegt zeitlich VOR env_a
  784.                 (die neue Spur muss nach LINKS verschoben werden /
  785.                  am Anfang beschnitten werden)
  786.     """
  787.     a = normalize_envelope(env_a)
  788.     b = normalize_envelope(env_b)
  789.  
  790.     max_lag_windows = int(max_lag_s * 1000.0 / window_ms)
  791.  
  792.     # np.correlate mit mode='full' liefert alle Verschiebungen von
  793.     # -(len(b)-1) bis +(len(a)-1). Wir beschränken die Suche auf
  794.     # +/- max_lag_windows, um Rechenzeit zu sparen und Fehltreffer bei
  795.     # sehr langen Dateien zu vermeiden.
  796.     full_corr = np.correlate(a, b, mode="full")
  797.     # Index in full_corr, der lag=0 entspricht:
  798.     zero_lag_index = len(b) - 1
  799.  
  800.     lo = max(0, zero_lag_index - max_lag_windows)
  801.     hi = min(len(full_corr), zero_lag_index + max_lag_windows + 1)
  802.  
  803.     windowed_corr = full_corr[lo:hi]
  804.     best_index_local = int(np.argmax(windowed_corr))
  805.     best_lag_windows = (lo + best_index_local) - zero_lag_index
  806.  
  807.     # best_lag_windows ist der Lag, um den b gegenüber a verschoben werden
  808.     # muss (in Fenstereinheiten), damit die Korrelation maximal ist.
  809.     # np.correlate(a, b)[k] entspricht sum_n a[n] * b[n - (k - zero_lag_index)]
  810.     # -> positive best_lag_windows bedeutet: b muss nach RECHTS verschoben
  811.     #    werden (später einsetzen), damit es zu a passt.
  812.     offset_seconds = best_lag_windows * (window_ms / 1000.0)
  813.     return offset_seconds
  814.  
  815.  
  816. def determine_offset(original_path: str, original_stream_index: int,
  817.                       extracted_aac_path: str, tmp_dir: str) -> float:
  818.     """
  819.     Ermittelt die zeitliche Differenz (in Sekunden) zwischen der
  820.     Original-Audiospur und der frisch extrahierten AAC-Spur.
  821.     """
  822.     wav_orig = os.path.join(tmp_dir, "orig_analysis.wav")
  823.     wav_new = os.path.join(tmp_dir, "new_analysis.wav")
  824.  
  825.     extract_audio_to_wav_mono(original_path, original_stream_index, wav_orig)
  826.     # Extrahierte AAC-Datei hat nur einen Audiostream -> Index 0
  827.     extract_audio_to_wav_mono(extracted_aac_path, 0, wav_new)
  828.  
  829.     samples_orig = read_wav_mono_pcm16(wav_orig)
  830.     samples_new = read_wav_mono_pcm16(wav_new)
  831.  
  832.     env_orig = compute_rms_envelope(samples_orig, ANALYSIS_SAMPLE_RATE)
  833.     env_new = compute_rms_envelope(samples_new, ANALYSIS_SAMPLE_RATE)
  834.  
  835.     if env_orig.size == 0 or env_new.size == 0:
  836.         raise RuntimeError("Couldn't calculate energy profile (maybe empty audio signal?)")
  837.  
  838.     offset_seconds = compute_offset_seconds(env_orig, env_new)
  839.     return offset_seconds
  840.  
  841.  
  842. # ---------------------------------------------------------------------------
  843. # Zusammenbau der finalen Datei
  844. # ---------------------------------------------------------------------------
  845.  
  846. def build_filled_start_audio(original_path: str, original_audio_index: int,
  847.                               extracted_aac_path: str, trim_duration_s: float,
  848.                               video_duration_s: float, tmp_dir: str) -> str:
  849.     """
  850.     Sonderfall Linksverschiebung (> 5s):
  851.     Die neue Spur liegt zeitlich VOR der Originalspur, d.h. ihr Anfang
  852.     (Dauer `trim_duration_s`) gehört eigentlich vor Timestamp 0 des Videos
  853.     und wird verworfen, damit die neue Spur synchron ab 0 beginnt. Dadurch
  854.     wird sie um `trim_duration_s` Sekunden kürzer als die Videolänge.
  855.     Diese fehlende Zeit entsteht am ENDE und wird mit dem entsprechenden
  856.     Ausschnitt vom Ende der Original-Audiospur aufgefüllt (den letzten
  857.     `trim_duration_s` Sekunden der Originalspur), sodass die resultierende
  858.     Spur wieder genau `video_duration_s` lang ist.
  859.  
  860.     Ablauf:
  861.         1. Anfang der neuen Spur um `trim_duration_s` kappen (-ss).
  862.         2. Die letzten `trim_duration_s` Sekunden der Original-Audiospur
  863.            extrahieren (Ausschnitt von video_duration_s - trim_duration_s
  864.            bis video_duration_s).
  865.         3. Beide Teile aneinanderhängen: [getrimmte neue Spur] + [Original-
  866.            Ausschnitt vom Ende].
  867.  
  868.     Rückgabe: Pfad zur zusammengesetzten AAC-Datei.
  869.     """
  870.     # 1. Anfang der neuen Spur verwerfen, damit sie synchron bei 0 beginnt.
  871.     trimmed_new_path = os.path.join(tmp_dir, "trimmed_new_for_fill.m4a")
  872.     run([
  873.         "ffmpeg", "-y",
  874.         "-i", str(extracted_aac_path),
  875.         "-ss", f"{trim_duration_s:.3f}",
  876.         "-c", "copy",
  877.         str(trimmed_new_path),
  878.     ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  879.  
  880.     # 2. Die dadurch am Ende fehlende Zeit aus dem ENDE der Original-
  881.     #    Audiospur holen.
  882.     fill_start_s = max(0.0, video_duration_s - trim_duration_s)
  883.     fill_part_path = os.path.join(tmp_dir, "fill_part_from_original_end.m4a")
  884.     run([
  885.         "ffmpeg", "-y",
  886.         "-i", str(original_path),
  887.         "-map", f"0:{original_audio_index}",
  888.         "-ss", f"{fill_start_s:.3f}",
  889.         "-vn", "-sn", "-dn",
  890.         "-c:a", "aac",
  891.         "-ar", str(AAC_SAMPLE_RATE),
  892.         "-b:a", "192k",
  893.         str(fill_part_path),
  894.     ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  895.  
  896.     # 3. Zusammenfügen per concat-Filter (statt concat-Demuxer), damit
  897.     #    unterschiedliche Encoder-Zustände/Timestamps der beiden Zuspieler
  898.     #    sauber neu encodiert und nahtlos aneinandergereiht werden.
  899.     combined_path = os.path.join(tmp_dir, "combined_end_filled.m4a")
  900.     run([
  901.         "ffmpeg", "-y",
  902.         "-i", str(trimmed_new_path),
  903.         "-i", str(fill_part_path),
  904.         "-filter_complex", "[0:a][1:a]concat=n=2:v=0:a=1[aout]",
  905.         "-map", "[aout]",
  906.         "-c:a", "aac",
  907.         "-ar", str(AAC_SAMPLE_RATE),
  908.         "-b:a", "192k",
  909.         str(combined_path),
  910.     ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  911.  
  912.     return combined_path
  913.  
  914.  
  915. def mux_synced_audio(original_path: str, original_audio_index: int,
  916.                       extracted_aac_path: str, output_path: str,
  917.                       offset_seconds: float, video_duration_s: float,
  918.                       language_tag: str, tmp_dir: str) -> None:
  919.     """
  920.     Fügt die extrahierte (ggf. angepasste) AAC-Spur synchronisiert in eine
  921.     Kopie von `original_path` ein und schreibt das Ergebnis nach
  922.     `output_path`. Alle bestehenden Streams von `original_path` werden
  923.     unverändert übernommen (Kopie), die neue Audiospur wird angehängt.
  924.  
  925.     `original_audio_index` ist der ffmpeg-Streamindex der Original-
  926.     Audiospur in `original_path` (wird im Sonderfall Linksverschiebung
  927.     benötigt, um den Füllteil zu extrahieren).
  928.     `language_tag` wird als Sprach-Metadatum auf die neu eingefügte Spur
  929.     gesetzt (das ursprünglich per CLI gesuchte Sprach-Tag).
  930.     """
  931.     abs_offset = abs(offset_seconds)
  932.  
  933.     # Bestimme Anzahl vorhandener Audiostreams in original_path,
  934.     # damit wir wissen, welchen disposition-Index die neue Spur bekommt.
  935.     orig_streams = ffprobe_streams(original_path)
  936.     n_existing_audio = sum(1 for s in orig_streams if s.get("codec_type") == "audio")
  937.     new_audio_index = n_existing_audio  # 0-basiert, neue Spur ist die (n+1)-te
  938.  
  939.     input_args = ["-i", str(original_path)]
  940.     extra_input_path = extracted_aac_path
  941.     itsoffset_value = None
  942.     trim_start_s = 0.0
  943.  
  944.     if abs_offset <= NORMAL_THRESHOLD_S:
  945.         # Normalfall: minimaler oder kein Versatz -> ggf. kleiner Delay,
  946.         # kein Abschneiden nötig.
  947.         if offset_seconds > 0:
  948.             itsoffset_value = offset_seconds
  949.         elif offset_seconds < 0:
  950.             # Kleiner Linksversatz (<= 5s per Definition) -> Anfang leicht kappen
  951.             trim_start_s = abs_offset
  952.         log(f"Default Case: |offset|={abs_offset:.3f}s <= {NORMAL_THRESHOLD_S}s")
  953.  
  954.     elif offset_seconds > NORMAL_THRESHOLD_S:
  955.         # Rechtsverschiebung > 5s
  956.         if offset_seconds > RIGHT_SHIFT_HARD_LIMIT_S:
  957.             log(
  958.                 f"Special Case right shift: offset={offset_seconds:.3f}s "
  959.                 f"> {RIGHT_SHIFT_HARD_LIMIT_S}s -> building in delay, cutting off end"
  960.             )
  961.         else:
  962.             log(
  963.                 f"Right shift: offset={offset_seconds:.3f}s "
  964.                 f"(between {NORMAL_THRESHOLD_S}s and {RIGHT_SHIFT_HARD_LIMIT_S}s)"
  965.             )
  966.         itsoffset_value = offset_seconds
  967.  
  968.     else:
  969.         # offset_seconds < -NORMAL_THRESHOLD_S: Linksverschiebung > 5s.
  970.         # Anfang der neuen Spur wird verworfen (wie im Normalfall), die
  971.         # dadurch am ENDE fehlende Zeit wird mit dem entsprechenden
  972.         # Ausschnitt vom Ende der Original-Audiospur aufgefüllt, sodass
  973.         # die Gesamtlänge der resultierenden Spur der Videolänge entspricht.
  974.         log(
  975.             f"Special Case left shift: offset={offset_seconds:.3f}s "
  976.             f"< -{NORMAL_THRESHOLD_S}s -> Start of new stream will be cut off by "
  977.             f"{abs_offset:.3f}s , missing segment at end will be filled with "
  978.             f"original audio"
  979.         )
  980.         extra_input_path = build_filled_start_audio(
  981.             original_path, original_audio_index, extracted_aac_path,
  982.             abs_offset, video_duration_s, tmp_dir
  983.         )
  984.         # Die zusammengesetzte Spur beginnt bereits korrekt bei 0, ist
  985.         # synchron und hat bereits die volle Videolänge -> kein
  986.         # trim_start_s, kein itsoffset_value nötig.
  987.  
  988.     # Falls der Anfang beschnitten werden muss (nur im Normalfall bei
  989.     # kleinem Linksversatz), erst eine getrimmte Zwischen-Datei erzeugen -
  990.     # einfacher und robuster, als -ss direkt im finalen Mux-Kommando mit
  991.     # -itsoffset zu kombinieren.
  992.     if trim_start_s > 0:
  993.         trimmed_path = os.path.join(tmp_dir, "trimmed_new_audio.m4a")
  994.         run([
  995.             "ffmpeg", "-y",
  996.             "-i", str(extracted_aac_path),
  997.             "-ss", f"{trim_start_s:.3f}",
  998.             "-c", "copy",
  999.             str(trimmed_path),
  1000.         ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  1001.         extra_input_path = trimmed_path
  1002.  
  1003.     second_input_args = []
  1004.     if itsoffset_value is not None and itsoffset_value > 0:
  1005.         second_input_args += ["-itsoffset", f"{itsoffset_value:.3f}"]
  1006.     second_input_args += ["-i", str(extra_input_path)]
  1007.  
  1008.     cmd = ["ffmpeg", "-y"] + input_args + second_input_args + [
  1009.         "-map", "0",
  1010.         "-map", "1:a:0",
  1011.         "-c", "copy",
  1012.         f"-disposition:a:{new_audio_index}", "0",
  1013.         f"-metadata:s:a:{new_audio_index}", f"language={language_tag}",
  1014.     ]
  1015.  
  1016.     # Sonderfall Rechtsverschiebung > 10s: Ende auf Videolänge kappen.
  1017.     # -t begrenzt die Gesamtlänge des Outputs auf die Videodauer, sodass
  1018.     # der Überstand am Ende der (verzögerten) neuen Audiospur abgeschnitten wird.
  1019.     if itsoffset_value is not None and itsoffset_value > RIGHT_SHIFT_HARD_LIMIT_S:
  1020.         cmd += ["-t", f"{video_duration_s:.3f}"]
  1021.  
  1022.     cmd += [str(output_path)]
  1023.  
  1024.     run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  1025.  
  1026.     cmd = ["mkvpropedit", "--add-track-statistics-tags", output_path]
  1027.     run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  1028.  
  1029.  
  1030. # ---------------------------------------------------------------------------
  1031. # Hauptprogramm
  1032. # ---------------------------------------------------------------------------
  1033.  
  1034. def main():
  1035.     parser = argparse.ArgumentParser(
  1036.         description="Extrahiert eine Audiospur per Sprach-Tag aus einer zweiten "
  1037.                     "Datei, synchronisiert sie per Energieverlauf-Analyse mit der "
  1038.                     "Originalspur und fügt sie synchronisiert in das Originalvideo ein."
  1039.     )
  1040.     parser.add_argument("original", help="Pfad zur Originaldatei (Video)")
  1041.     parser.add_argument("second_file", help="Pfad zur zweiten Datei, aus der die Audiospur extrahiert wird")
  1042.     parser.add_argument("output", help="Pfad zur Ausgabedatei")
  1043.     parser.add_argument("language_tag", help="Gesuchtes Sprach-Tag, z. B. 'de', 'eng', 'ja'")
  1044.     args = parser.parse_args()
  1045.  
  1046.     original_path = Path(args.original)
  1047.     second_path = Path(args.second_file)
  1048.     output_path = Path(args.output)
  1049.  
  1050.     for p in (original_path, second_path):
  1051.         if not p.is_file():
  1052.             log(f"Error: File not found: {p}")
  1053.             sys.exit(1)
  1054.  
  1055.     with tempfile.TemporaryDirectory(prefix="sync_audio_") as tmp_dir:
  1056.         log(f"Searching audio stream with language tag '{args.language_tag}' in {second_path}")
  1057.         target_stream = find_audio_stream_by_language(str(second_path), args.language_tag)
  1058.         target_index = target_stream["index"]
  1059.         log(f"Gefunden: Stream-Index {target_index} "
  1060.             f"(Codec: {target_stream.get('codec_name')}, "
  1061.             f"Language: {(target_stream.get('tags', {}) or {}).get('language')})")
  1062.  
  1063.         extracted_aac_path = os.path.join(tmp_dir, "extracted_audio.m4a")
  1064.         log(f"Extracting audio stream to AAC ({AAC_SAMPLE_RATE} Hz) -> {extracted_aac_path}")
  1065.         extract_audio_to_aac(str(second_path), target_index, extracted_aac_path)
  1066.  
  1067.         log("Calculating default/first audio stream of original file for comparison")
  1068.         original_audio_stream = find_default_or_first_audio_stream(str(original_path))
  1069.         original_audio_index = original_audio_stream["index"]
  1070.         log(f"Original audio stream: Stream-Index {original_audio_index}")
  1071.  
  1072.         log("Calculating time shift using energy profile cross correlation")
  1073.         offset_seconds = determine_offset(
  1074.             str(original_path), original_audio_index, extracted_aac_path, tmp_dir
  1075.         )
  1076.         log(f"Calculating time shift (Offset): {offset_seconds:.3f} s "
  1077.             f"({'new stream later' if offset_seconds > 0 else 'new stream earlier' if offset_seconds < 0 else 'no shift'})")
  1078.  
  1079.         video_duration_s = ffprobe_format_duration(str(original_path))
  1080.         log(f"Video length (Original): {video_duration_s:.3f} s")
  1081.  
  1082.         log(f"Muxing synchronized audio stream in output file -> {output_path}")
  1083.         mux_synced_audio(
  1084.             str(original_path), original_audio_index, extracted_aac_path,
  1085.             str(output_path), offset_seconds, video_duration_s,
  1086.             args.language_tag, tmp_dir
  1087.         )
  1088.  
  1089.     log("Done muxing new file.")
  1090.  
  1091.  
  1092. if __name__ == "__main__":
  1093.     main()
  1094. PY
  1095. }
  1096. main "$@"
  1097.  
Add Comment
Please, Sign In to add comment