├── media-converter ├── status ├── Dockerfile ├── convert_media ├── .github └── workflows │ └── image.yml ├── compile_binaries ├── README.md └── functions /media-converter: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | main(){ 4 | container_setup 5 | media_converter_loop 6 | } 7 | 8 | echo "puid=${PUID}" > /tmp/container_facts 9 | echo "pgid=${PGID}" >> /tmp/container_facts 10 | echo "tz=${TZ}" >> /tmp/container_facts 11 | echo "encode=${ENCODE}" >> /tmp/container_facts 12 | echo "media_server=${MEDIA_SERVER}" >> /tmp/container_facts 13 | 14 | source /opt/functions 15 | 16 | main 17 | -------------------------------------------------------------------------------- /status: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | main(){ 4 | clear 5 | get_container_uptime 6 | echo -e "\e[1;97mContainer created: $(date -d @$(stat -c %Y /var/tmp/.media-converter.create) "+%b-%d-%Y %H:%M")" 7 | echo -e "Container uptime: ${container_uptime}\n" 8 | check_space "/media" 9 | declare -i tv_count=$(ls -1 "${tv_convert}" | egrep -v "-converted.mp4|.srt") 10 | declare -i movie_count=$(ls -1 "${movie_convert}" | egrep -v "-converted.mp4|.srt") 11 | print_info_no_log "\n$(tv_grammar ${tv_count}) waiting to be encoded" 12 | print_info_no_log "$(movie_grammar ${movie_count}) waiting to be encoded\n" 13 | [[ $(pgrep ffmpeg) ]] && print_notice_no_log "FFMPEG is running" 14 | if [[ $(pgrep HandBrakeCLI) ]]; then 15 | print_notice_no_log "HandBrake is converting:\n$(ps -ef|grep -o "\-\-output.*"|sed -e 's/\-\-output \/media\/Complete\/Convert\/.*\///' -e 's/\-converted\.mp4//')" 16 | else 17 | print_notice_no_log "No current encode jobs running" 18 | fi 19 | } 20 | 21 | source /opt/functions 22 | 23 | main 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | #Download base image debian 2 | FROM debian:latest 3 | 4 | # Set default docker variables 5 | ENV PUID=${PUID:-1000} \ 6 | PGID=${PGID:-1000} \ 7 | TZ=${TZ:-America/New_York} \ 8 | ENCODE=${ENCODE:-x264} \ 9 | MEDIA_SERVER=${MEDIA_SERVER:-yes} 10 | 11 | # Set container labels 12 | LABEL build_version="Media-Converter, Version: 2.2.8 Build-date: 2021-Jun-14" maintainer="parker-hemphill" 13 | 14 | # Copy Handbrake and media-info compile script to /tmp 15 | COPY compile_binaries /tmp/ 16 | 17 | # Compile HandBrakeCLI and media-info from latest source 18 | RUN /tmp/compile_binaries 19 | 20 | # Copy shell scripts and functions to container 21 | COPY status convert_media /usr/local/bin/ 22 | COPY media-converter /usr/local/bin/ 23 | COPY functions /opt/ 24 | 25 | # Copy shell scripts to /usr/local/bin 26 | COPY status convert_media /usr/local/bin/ 27 | COPY media-converter /usr/local/bin/ 28 | 29 | # Copy functions to /opt 30 | COPY functions /opt/ 31 | 32 | # Run the command on container start 33 | ENTRYPOINT ["/usr/local/bin/media-converter"] 34 | -------------------------------------------------------------------------------- /convert_media: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | main(){ 4 | readonly media_convert_directory=$(get_media_convert_directory "${MEDIA_TYPE}") 5 | readonly media_import_directory=$(get_media_import_directory "${MEDIA_TYPE}") 6 | readonly mkv_media_log="${mkvlog}${MEDIA_TYPE,,}.log" 7 | readonly mp4_media_log="${mp4log}${MEDIA_TYPE,,}.log" 8 | readonly failed_media_log="${failedlog}${MEDIA_TYPE,,}.log" 9 | readonly media_handbrake_log="${handbrake_log}${MEDIA_TYPE,,}.log" 10 | # Remove previous "output" file since it's an incomplete conversion from container shutdown 11 | if [[ $(find "${media_convert_directory}" -newermt $(date +%Y-%m-%d -d '20 year ago') -type f) ]]; then 12 | local -r input_file=$(ls -1t "${media_convert_directory}"|head -1) 13 | else 14 | exit 0 15 | fi 16 | local -r input_extension="${input_file^^}" 17 | if [[ "${input_extension: -4}" == ".MKV" ]]; then 18 | convert_mkv "${input_file}" "${media_convert_directory}" 19 | else 20 | convert_handbrake "${input_file}" "${media_convert_directory}" "${media_import_directory}" 21 | fi 22 | exit 0 23 | } 24 | 25 | source /opt/functions 26 | 27 | MEDIA_SERVER="${1}" 28 | ENCODE="${2}" 29 | MEDIA_TYPE="${3}" 30 | 31 | # Set niceness (Priority) of conversion process 32 | declare -r nice_level=$(set_priority "${MEDIA_SERVER}") 33 | 34 | main 35 | -------------------------------------------------------------------------------- /.github/workflows/image.yml: -------------------------------------------------------------------------------- 1 | name: buildx 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * 0' 6 | pull_request: 7 | branches: master 8 | push: 9 | branches: master 10 | tags: 11 | - v* 12 | 13 | jobs: 14 | buildx: 15 | runs-on: ubuntu-20.04 16 | steps: 17 | - 18 | name: Checkout 19 | uses: actions/checkout@v2 20 | - 21 | name: Prepare 22 | id: prepare 23 | run: | 24 | DOCKER_IMAGE=parkerhemphill/media-converter 25 | DOCKER_PLATFORMS=linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 26 | VERSION=latest 27 | 28 | if [[ $GITHUB_REF == refs/tags/* ]]; then 29 | VERSION=${GITHUB_REF#refs/tags/v} 30 | fi 31 | if [ "${{ github.event_name }}" = "schedule" ]; then 32 | VERSION=latest 33 | fi 34 | 35 | TAGS="--tag ${DOCKER_IMAGE}:${VERSION}" 36 | 37 | echo ::set-output name=docker_image::${DOCKER_IMAGE} 38 | echo ::set-output name=version::${VERSION} 39 | echo ::set-output name=buildx_args::--platform ${DOCKER_PLATFORMS} \ 40 | --build-arg VERSION=${VERSION} \ 41 | --build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \ 42 | --build-arg VCS_REF=${GITHUB_SHA::8} \ 43 | ${TAGS} . 44 | - 45 | name: Set up QEMU 46 | uses: docker/setup-qemu-action@v1 47 | - 48 | name: Set up Docker Buildx 49 | uses: docker/setup-buildx-action@v1 50 | - 51 | name: Docker Buildx (build) 52 | run: | 53 | docker buildx build --output "type=image,push=false" ${{ steps.prepare.outputs.buildx_args }} 54 | - 55 | name: Login to DockerHub 56 | if: success() && github.event_name != 'pull_request' 57 | uses: docker/login-action@v1 58 | with: 59 | username: ${{ secrets.DOCKER_USERNAME }} 60 | password: ${{ secrets.DOCKER_TOKEN }} 61 | - 62 | name: Docker Buildx (push) 63 | if: success() && github.event_name != 'pull_request' 64 | run: | 65 | docker buildx build --output "type=image,push=true" ${{ steps.prepare.outputs.buildx_args }} 66 | -------------------------------------------------------------------------------- /compile_binaries: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo -e " ---> Downloading updates and compiling binaries" 3 | echo -e " ---> Updating apt package cache"; \ 4 | apt-get update > /dev/null 2>&1; \ 5 | echo -e " ---> Updating base image packages"; \ 6 | apt-get upgrade -y > /dev/null 2>&1; \ 7 | apt-get install -y --no-install-recommends apt-utils > /dev/null 2>&1; \ 8 | echo 'Set disable_coredump false' > /etc/sudo.conf; \ 9 | echo -e " ---> Installing required packages"; \ 10 | apt-get install -y --no-install-recommends autoconf automake build-essential ca-certificates cmake git git-core gntp-send gnutls-bin libass9 libass-dev libbz2-dev libfontconfig1-dev libfreetype6-dev libfribidi-dev libgnutls28-dev libharfbuzz-dev libjansson-dev liblzma-dev libmp3lame-dev libnuma-dev libogg-dev libopus-dev libsamplerate-dev libspeex-dev libtheora-dev libtool libtool-bin libturbojpeg0-dev libunistring-dev libvorbis-dev libva-dev libdrm-dev libvpx-dev libx264-dev libxcb-xfixes0-dev libxml2-dev m4 make mawk mediainfo meson nasm ninja-build patch pkg-config procps python sudo tar texinfo tzdata wget yasm zlib1g-dev > /dev/null 2>&1; \ 11 | mkdir -p /tmp/ffmpeg_sources; \ 12 | mkdir -p /tmp/handbrake; \ 13 | mkdir -p /ffmpeg_build; \ 14 | echo -e "\t1/10 : Compiling x264"; \ 15 | cd /tmp/ffmpeg_sources; \ 16 | git clone --depth 1 https://code.videolan.org/videolan/x264.git > /dev/null 2>&1; \ 17 | cd x264; \ 18 | PKG_CONFIG_PATH="/ffmpeg_build/lib/pkgconfig" ./configure --prefix="/ffmpeg_build" --bindir="/usr/local/bin" --enable-static --enable-pic > /dev/null 2>&1; \ 19 | make -j$(nproc) > /dev/null 2>&1; \ 20 | make install > /dev/null 2>&1; \ 21 | echo -e "\t2/10 : Compiling x265"; \ 22 | cd /tmp/ffmpeg_sources; \ 23 | git clone https://bitbucket.org/multicoreware/x265_git > /dev/null 2>&1; \ 24 | cd x265_git/build/linux; \ 25 | cmake -G "Unix Makefiles" -DCMAKE_INSTALL_PREFIX="/ffmpeg_build" -DENABLE_SHARED=off ../../source > /dev/null 2>&1; \ 26 | make -j$(nproc) > /dev/null 2>&1; \ 27 | make install > /dev/null 2>&1; \ 28 | echo -e "\t3/10 : Compiling libvpx"; \ 29 | cd /tmp/ffmpeg_sources; \ 30 | git clone --depth 1 https://chromium.googlesource.com/webm/libvpx.git > /dev/null 2>&1; \ 31 | cd libvpx; \ 32 | ./configure --prefix="/ffmpeg_build" --disable-examples --disable-unit-tests --enable-vp9-highbitdepth --as=yasm > /dev/null 2>&1; \ 33 | make -j$(nproc) > /dev/null 2>&1; \ 34 | make install > /dev/null 2>&1; \ 35 | echo -e "\t4/10 : Compiling libfdk-aac"; \ 36 | cd /tmp/ffmpeg_sources; \ 37 | git clone --depth 1 https://github.com/mstorsjo/fdk-aac > /dev/null 2>&1; \ 38 | cd fdk-aac; \ 39 | autoreconf -fiv > /dev/null 2>&1; \ 40 | ./configure --prefix="/ffmpeg_build" --disable-shared > /dev/null 2>&1; \ 41 | make -j$(nproc) > /dev/null 2>&1; \ 42 | make install > /dev/null 2>&1; \ 43 | echo -e "\t5/10 : Compiling libmp3lame"; \ 44 | cd /tmp/ffmpeg_sources; \ 45 | wget -O lame-3.100.tar.gz https://downloads.sourceforge.net/project/lame/lame/3.100/lame-3.100.tar.gz > /dev/null 2>&1; \ 46 | tar xzvf lame-3.100.tar.gz > /dev/null 2>&1; \ 47 | cd lame-3.100; \ 48 | ./configure --prefix="/ffmpeg_build" --bindir="/usr/local/bin" --disable-shared --enable-nasm > /dev/null 2>&1; \ 49 | make -j$(nproc) > /dev/null 2>&1; \ 50 | make install > /dev/null 2>&1; \ 51 | echo -e "\t6/10 : Compiling libopus"; \ 52 | cd /tmp/ffmpeg_sources; \ 53 | git clone --depth 1 https://github.com/xiph/opus.git > /dev/null 2>&1; \ 54 | cd opus; \ 55 | ./autogen.sh > /dev/null 2>&1; \ 56 | ./configure --prefix="/ffmpeg_build" --disable-shared > /dev/null 2>&1; \ 57 | make -j$(nproc) > /dev/null 2>&1; \ 58 | make install > /dev/null 2>&1; \ 59 | echo -e "\t7/10 : Compiling libaom"; \ 60 | cd /tmp/ffmpeg_sources; \ 61 | git clone --depth 1 https://aomedia.googlesource.com/aom > /dev/null 2>&1; \ 62 | mkdir -p aom_build; \ 63 | cd aom_build; \ 64 | cmake -G "Unix Makefiles" -DCMAKE_INSTALL_PREFIX="/ffmpeg_build" -DENABLE_SHARED=off -DENABLE_NASM=on ../aom > /dev/null 2>&1; \ 65 | make -j$(nproc) > /dev/null 2>&1; \ 66 | make install > /dev/null 2>&1; \ 67 | echo -e "\t8/10 : Compiling libsvtav1"; \ 68 | cd /tmp/ffmpeg_sources; \ 69 | git clone https://github.com/AOMediaCodec/SVT-AV1.git > /dev/null 2>&1; \ 70 | mkdir -p SVT-AV1/build; \ 71 | cd SVT-AV1/build; \ 72 | cmake -G "Unix Makefiles" -DCMAKE_INSTALL_PREFIX="/ffmpeg_build" -DCMAKE_BUILD_TYPE=Release -DBUILD_DEC=OFF -DBUILD_SHARED_LIBS=OFF .. > /dev/null 2>&1; \ 73 | make -j$(nproc) > /dev/null 2>&1; \ 74 | make install > /dev/null 2>&1; \ 75 | echo -e "\t9/10 : Compiling FFmpeg"; \ 76 | cd /tmp/ffmpeg_sources; \ 77 | wget -O ffmpeg-snapshot.tar.bz2 https://ffmpeg.org/releases/ffmpeg-snapshot.tar.bz2 > /dev/null 2>&1; \ 78 | tar xjvf ffmpeg-snapshot.tar.bz2 > /dev/null 2>&1; \ 79 | cd ffmpeg; \ 80 | PKG_CONFIG_PATH="/ffmpeg_build/lib/pkgconfig" ./configure \ 81 | --prefix="/ffmpeg_build" \ 82 | --pkg-config-flags="--static" \ 83 | --extra-cflags="-I/ffmpeg_build/include" \ 84 | --extra-ldflags="-L/ffmpeg_build/lib" \ 85 | --extra-libs="-lpthread -lm" \ 86 | --bindir="/usr/local/bin" \ 87 | --enable-gpl \ 88 | --enable-gnutls \ 89 | --enable-libaom \ 90 | --enable-libass \ 91 | --enable-libfdk-aac \ 92 | --enable-libfreetype \ 93 | --enable-libmp3lame \ 94 | --enable-libopus \ 95 | --enable-libsvtav1 \ 96 | --enable-libvorbis \ 97 | --enable-libvpx \ 98 | --enable-libx264 \ 99 | --enable-libx265 \ 100 | --enable-nonfree > /dev/null 2>&1; \ 101 | make -j$(nproc) > /dev/null 2>&1; \ 102 | make install > /dev/null 2>&1; \ 103 | echo -e "\t10/10 : Compiling HandbrakeCLI"; \ 104 | cd /tmp/handbrake; \ 105 | git clone https://github.com/HandBrake/HandBrake.git > /dev/null 2>&1; \ 106 | cd HandBrake; \ 107 | ./configure --launch-jobs=$(nproc) --launch --enable-qsv --disable-gtk > /dev/null 2>&1; \ 108 | make --directory=build install > /dev/null 2>&1; \ 109 | cd /tmp; \ 110 | rm -rf * > /dev/null 2>&1; \ 111 | echo -e " ---> Removing build packages no longer needed and cleaning up temporary source files and directories"; \ 112 | apt-get autoremove -y autoconf automake build-essential cmake git-core wget > /dev/null 2>&1 113 | echo -e " ---> Creating Docker creation datestamp" 114 | touch /var/tmp/.media-converter.create 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # parkerhemphill/media-converter 2 | ## A simple docker image that uses FFMPEG, media-info, and HandBrakeCLI to convert downloaded media into mp4 3 | ## Current Version: 2.1.8 updated 2021-Apr-28 4 | ## NEW: Uses GitHub actions to rebuild with latest libraries every Sunday at midnight UTC. 5 | ### Update: 2.1.8 adds better support for failed files 6 | ### Update: 2.1.7 adds support for case insensitive filename extensions 7 | ### Update: 2.1.3 adds ARM support to image 8 | ### Update: 2.0.0 compiles the latest HandBrake and FFMPEG from source each time you build the container with the dockerfile. Regular updates to the container also ensure any images pulled from dockerhub will also have a recent version of HandBrake and FFMPEG 9 | [![Docker Stars](https://img.shields.io/docker/stars/parkerhemphill/media-converter)](https://store.docker.com/community/images/parkerhemphill/media-converter) 10 | [![Docker Pulls](https://img.shields.io/docker/pulls/parkerhemphill/media-converter)](https://store.docker.com/community/images/parkerhemphill/media-converter) 11 | ### Flow of operations: 12 | * 1: Download client places files in `'/Complete/'` 13 | * ~~Crontab runs every five minutes to move completed files from~~ Crontab was unreliable so instead we use an infinite loop to perform the move and convert actions with a wait of 30 seconds between checks. First action is to move: `'/Complete/'` to `'/Complete/Convert/'` 14 | * 2: Files are converted 15 | * ~~Crontab runs every two minutes to convert media files in~~ Loop checks for media to convert in `'/Complete/Convert/'` 16 | * If file is an 'mkv' file *ffmpeg* converts into an 'mp4' file for conversion and removes 'mkv' file 17 | * *media-info* checks the height and width, along with other attributes to determine ideal converter settings, including bitrate for video 18 | * *HandBrakeCLI* uses the determined settings to convert the media into MP4 H264 media named **\-converted.mp4**
19 | NOTE: If container is shutdown in the middle of conversion it will remove existing \*-converted.mp4 files upon restart, since these would be an incomplete converted file 20 | * 3: Completed file is moved to `'/Complete/IMPORT/'`, ready to be ingested by SickChill, etc. into Plex/Jellyfin/Kodi library 21 | 22 | ### Notes: 23 | * ~~Growl Notifications for macOS users. Simply pass GROWL=YES, GROWL_IP=, and GROWL_PORT= ENV variables~~ 24 | * Removed Growl since it is no longer actively developed for macOS 25 | * The option to choose h264 or HVEC (h265) has been added to the image, simply pass **"- ENCODE="** to environment for container (**Defaults to h264 if variable isn't set**) 26 | * ~~All files converted are added to a logfile located at **\/Logs/converted.log**~~ 27 | * UPDATE 2.1.5: Log files are now created under **\/logs** and contain individual logfiles for MKV, MP4, and handbrake command used to convert each media file 28 | * Upon start-up container will check if '\' is writeable by PUID and create the needed directories if they don't exist 29 | * Container defaults to uid/gid 1000 if PUID/PGID aren't specified in the environment settings 30 | * The easiest way to get up and running is to start the image, then go into the setup of your download client (SickChill/Deluge, etc) and set it to place completed media in `'/Complete/'` 31 | * Set `'/Complete/IMPORT/'` as the *import* directory for your download client (SickChill, etc) 32 | * EXAMPLE: You'd set your download client to place completed files in `'/media/media/Complete/'`, where they'll be grabbed and converted, then moved into `'/media/media/Complete/IMPORT/'` for your media manager to import to the Plex/Jellyfin/Kodi library 33 | * Inside the docker container all paths are under `'/media'`, outside the container paths are relative to `''` 34 | * On host: `'/media/media'` 35 | * Inside container: `'/media'` 36 | ## Docker-compose example 37 | * In this example I use `'/media/media'` as the mount point on my server and UID "1000" to map my primary user to the container. You can get the UID/GID of desired user by running `id `. I.E. `id plex` 38 | * Change "TZ" to match your desired timezone. A vaild list can be found at https://www.wikiwand.com/en/List_of_tz_database_time_zones under the "TZ database name" column. Default is "America/New_York" 39 | * Change "ENCODE" to `'x264'` or `'x265'` to use h264 (default if option isn't set) or h265 (HVEC) 40 | * If this is purely a host for converting media you can add the enviromental variable "MEDIA_SERVER=no" to give more processing power to the HandBrakeCLI process (Defaults to yes and normal priority if not set) 41 | ``` 42 | #docker-compose.yaml 43 | version: "3" 44 | services: 45 | media-converter: 46 | image: parkerhemphill/media-converter:latest 47 | container_name: media-converter 48 | network_mode: host 49 | environment: 50 | - PUID=1000 51 | - PGID=1000 52 | - TZ=America/New_York 53 | - ENCODE=x264 54 | volumes: 55 | - /media/media:/media 56 | restart: unless-stopped 57 | ``` 58 | ## Docker run example 59 | * In this example I use `'/media/media'` as the mount point on my server and UID "1000" to map my primary user to the container. You can get the needed UID/GID by running `id `. I.E. `id plex` 60 | * Change "TZ" to match your desired timezone. A vaild list can be found at https://www.wikiwand.com/en/List_of_tz_database_time_zones under the "TZ database name" column. Default is "America/New_York" 61 | * Change "ENCODE" to `'x264'` or `'x265'` to use h264 (default if option isn't set) or h265 (HVEC) 62 | ``` 63 | docker run -d \ 64 | --name=media-converter \ 65 | -e PUID=1000 \ 66 | -e PGID=1000 \ 67 | -e TZ=America/New_York \ 68 | -e ENCODE=x264 \ 69 | -v /media/media:/media \ 70 | --restart unless-stopped \ 71 | parkerhemphill/media-converter:latest 72 | ``` 73 | ## Support 74 | * Shell access while the container is running:
75 | `docker exec -it media-converter /bin/bash` 76 | * Monitor currently encoding media:
77 | `docker exec -it media-converter status` 78 | * Container version number:
79 | `docker inspect -f '{{ index .Config.Labels "build_version" }}' media-converter` 80 | * Image version number:
81 | `docker inspect -f '{{ index .Config.Labels "build_version" }}' parkerhemphill/media-converter` 82 | -------------------------------------------------------------------------------- /functions: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # Variables # 3 | ################################################################################ 4 | 5 | # Source docker ENV variables 6 | . /tmp/container_facts 7 | 8 | # Set PATH 9 | export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 10 | 11 | # Location to create log files 12 | readonly log_dir="/media/log" 13 | readonly log="${log_dir}/container.log" 14 | readonly handbrake_log="${log_dir}/HandBrake_Options_" 15 | readonly mkvlog="${log_dir}/converted-mkv_" 16 | readonly mp4log="${log_dir}/converted-mp4_" 17 | readonly failedlog="${log_dir}/FAILED_" 18 | 19 | # Location of binaries used by container 20 | readonly handbrake=/usr/local/bin/HandBrakeCLI 21 | readonly ffmpeg=/usr/local/bin/ffmpeg 22 | readonly mediainfo=/usr/bin/mediainfo 23 | 24 | # Base directory for media files 25 | readonly media_base="/media/Complete" 26 | 27 | # Location to check for new media files 28 | readonly movie_add="${media_base}/Movies" 29 | readonly tv_add="${media_base}/TVShows" 30 | 31 | # Location to move media files to for conversion 32 | readonly movie_convert="${media_base}/Convert/Movies" 33 | readonly tv_convert="${media_base}/Convert/TVShows" 34 | 35 | # Location to move media files to for ingestion by SickChill, CoachPotato, etc 36 | readonly movie_import="${media_base}/IMPORT/Movies" 37 | readonly tv_import="${media_base}/IMPORT/TVShows" 38 | 39 | # Set colors for status message 40 | readonly red='\e[1;31m' 41 | readonly yellow='\e[1;33m' 42 | readonly green='\e[1;32m' 43 | readonly white='\e[1;97m' 44 | readonly clear='\e[0m' 45 | 46 | ################################################################################ 47 | # Functions # 48 | ################################################################################ 49 | 50 | # Exit function when script misbehaves 51 | die(){ 52 | print_info "Fail point: ${BASH_SOURCE[1]}: line ${BASH_LINENO[0]}: ${FUNCNAME[1]}" >&2 53 | exit 1 54 | } 55 | 56 | # Create status messages 57 | print_error(){ echo -e "${red}[ERROR]: ${1}${clear}"|tee -a ${log}; } 58 | print_error_no_log(){ echo -e "${red}[ERROR]: ${1}${clear}"; } 59 | print_warning(){ echo -e "${yellow}[WARNING]: ${1}${clear}"|tee -a ${log}; } 60 | print_warning_no_log(){ echo -e "${yellow}[WARNING]: ${1}${clear}"; } 61 | print_info(){ echo -e "${white}${1}${clear}"|tee -a ${log}; } 62 | print_info_no_log(){ echo -e "${white}${1}${clear}"; } 63 | print_notice(){ echo -e "${white}[NOTICE]: ${1}${clear}"|tee -a ${log}; } 64 | print_notice_no_log(){ echo -e "${white}[NOTICE]: ${1}${clear}"; } 65 | print_ok(){ echo -e "${green}[OK]: ${1}${clear}"|tee -a ${log}; } 66 | print_ok_no_log(){ echo -e "${green}[OK]: ${1}${clear}"; } 67 | 68 | # Set mod time of dummyfile so we can generate uptime for container 69 | container_uptime(){ 70 | touch /var/tmp/.media-converter.uptime 71 | } 72 | 73 | # Add "media" user and group, and map them to provided UID/GID or 1000 if not provided 74 | add_user(){ 75 | if [[ ! $(grep 'media' /etc/passwd) ]]; then 76 | groupadd -g "${2}" media 77 | useradd -s /bin/bash -m -u "${1}" -g media media 78 | print_info "\"media\" user is mapped to external UID $(id -u media)" 79 | print_info "\"media\" group is mapped to external GID $(id -g media)" 80 | fi 81 | } 82 | 83 | # Set timezone inside container for logfile entries 84 | set_timezone(){ 85 | echo "${tz}"|tee /etc/timezone 86 | dpkg-reconfigure -f noninteractive tzdata > /dev/null 2>&1 87 | print_info "Timezone set to ${tz}" 88 | print_info "Current date and time inside container: $(date +%b-%d" "%H:%M)" 89 | } 90 | 91 | # Setup logfile 92 | setup_logfile(){ 93 | for media_type in tv movie; do 94 | for logfile in mkvlog mp4log failedlog handbrake_log; do 95 | if [[ ! -f "${!logfile}${media_type}.log" ]]; then 96 | touch "${!logfile}${media_type}.log" 97 | chown media:media "${!logfile}${media_type}.log" 98 | fi 99 | done 100 | done 101 | } 102 | 103 | # Check if /media is mounted to an external volume and writeable by media user 104 | check_mount(){ 105 | if ! mountpoint /media; then 106 | print_error "\"/media\" NOT mounted to external volume" 107 | die 108 | fi 109 | } 110 | 111 | # Create directory passed to function and set ownership to \"media\" user" 112 | create_directory(){ 113 | local -r directory="${1}" 114 | if [[ -d "${directory}" ]]; then 115 | directory_writeable "${directory}" 116 | else 117 | if [[ $(mkdir -p "${directory}") ]]; then 118 | print_ok "Created ${directory}" 119 | else 120 | print_error "Unable to create ${directory}" 121 | die 122 | fi 123 | chown media:media "${directory}" 124 | fi 125 | } 126 | 127 | # Print niceness level for logfile 128 | print_priority_info(){ 129 | if [[ ${media_server} == "yes" ]]; then 130 | print_info "\"MEDIA_SERVER\" variable set to ${media_server}, leaving default niceness for converter functions" 131 | else 132 | print_info "\"MEDIA_SERVER\" variable set to ${media_server}, lowering niceness for converter functions" 133 | fi 134 | } 135 | 136 | # Set niceness level of encoder actions 137 | set_priority(){ 138 | if [[ ${1} == "yes" ]]; then 139 | nice_level='-0' 140 | else 141 | nice_level='-15' 142 | fi 143 | echo "${nice_level}" 144 | } 145 | 146 | # Check if directory passed to function is writeable by \"media\" user" 147 | directory_writeable(){ 148 | sudo -u media bash -c "source /opt/functions && if [[ ! -w \"${1}\" ]]; then \ 149 | print_error \"${1} not writeable by UID ${puid}\"; \ 150 | die; \ 151 | fi" 152 | } 153 | 154 | # This ignores any files that might be sample media files (TV shows under 50MB in size and Movies under 500MB) 155 | move_media(){ 156 | if [[ "$(ls -A "${tv_add}")" ]]; then 157 | find "${tv_add}/" -type f -not -name '*sample*' -size +50M -regex '.*\.\(avi\|mod\|mpg\|mp4\|m4v\|mkv\)' -exec mv {} "${tv_convert}/" \; 158 | fi 159 | if [[ "$(ls -A "${movie_add}")" ]]; then 160 | find "${movie_add}/" -type f -not -name '*sample*' -size +500M -regex '.*\.\(avi\|mod\|mpg\|mp4\|m4v\|mkv\)' -exec mv {} "${movie_convert}/" \; 161 | fi 162 | # Remove left behind files older than 7 days 163 | find "${tv_add}/" -ctime +7 -exec rm {} + 164 | find "${movie_add}/" -ctime +7 -exec rm {} + 165 | } 166 | 167 | # Generate uptime and container creation time 168 | get_container_uptime(){ 169 | container_start=$(stat -c %Y /var/tmp/.media-converter.uptime) 170 | current_epoch=$(date +%s) 171 | num=$((current_epoch - container_start)) 172 | min=0 173 | hour=0 174 | day=0 175 | if((num>59));then 176 | ((sec=num%60)) 177 | ((num=num/60)) 178 | if((num>59));then 179 | ((min=num%60)) 180 | ((num=num/60)) 181 | if((num>23));then 182 | ((hour=num%24)) 183 | ((day=num/24)) 184 | else 185 | ((hour=num)) 186 | fi 187 | else 188 | ((min=num)) 189 | fi 190 | else 191 | ((sec=num)) 192 | fi 193 | container_uptime="${day}d ${hour}h ${min}m ${sec}s" 194 | } 195 | 196 | # Set "Movie" or "Movies" in convert count 197 | movie_grammar(){ 198 | local -r movie_count=${1} 199 | if [[ ${movie_count} -eq 1 ]]; then 200 | echo "There is ${movie_count} Movie" 201 | else 202 | echo "There are ${movie_count} Movies" 203 | fi 204 | } 205 | 206 | # Set "show" or "shows" in convert count 207 | tv_grammar(){ 208 | local -r tv_count=${1} 209 | if [[ ${tv_count} -eq 1 ]]; then 210 | echo "There is ${tv_count} TV episode" 211 | else 212 | echo "There are ${tv_count} TV episodes" 213 | fi 214 | } 215 | 216 | # Check free space for a device and provide mount point 217 | check_space(){ 218 | declare -i full=$(df -h "$1"|tail -1|awk '{print $5}'|tr -d %) 219 | (( full < 60 )) && print_ok "${1} is ${full}% full" 220 | (( full > 60 )) && (( ${full} < 75 )) && print_warning "${1} is ${full}% full" 221 | (( full > 75 )) && print_error "${1} is ${full}% full" 222 | } 223 | 224 | get_media_convert_directory(){ 225 | if [[ "${1}" == "TV" ]]; then 226 | local -r media_convert="${tv_convert}" 227 | else 228 | local -r media_convert="${movie_convert}" 229 | fi 230 | echo "${media_convert}" 231 | } 232 | 233 | get_media_import_directory(){ 234 | if [[ "${1}" == "TV" ]]; then 235 | local -r media_import="${tv_import}" 236 | else 237 | local -r media_import="${movie_import}" 238 | fi 239 | echo "${media_import}" 240 | } 241 | 242 | # Handle failed conversions by appending "FAILED" to filename and setting moddate to far in the past so it doesn't create conversion loop 243 | media_failure(){ 244 | local -r input_file="${1}" 245 | local -r media_input_directory="${2}" 246 | mv "${media_input_directory}/${input_file}" "${media_input_directory}/FAILED-${input_file}" 247 | touch -t 8001031305 "${media_input_directory}/FAILED-${input_file}" 248 | print_warning "\"${input_file}\" conversion failed" 249 | echo "$(date +%Y-%m-%d" "%H:%M): ${input_file}" >> "${failed_media_log}" 250 | exit 1 251 | } 252 | 253 | # Convert file to MP4 with FFMPEG 254 | convert_mkv(){ 255 | local -r input_file="${1}" 256 | local -r media_input_directory="${2}" 257 | local -r input="${media_input_directory}/${input_file}" 258 | local -r output="$(echo "${media_input_directory}/mkv-converted_${input_file}"| sed 's/....$/\.mp4/')" 259 | # Remove previous "output" file since it's an incomplete conversion from container shutdown 260 | if [[ -f "${output}" ]]; then 261 | rm "${output}" 262 | fi 263 | nice ${nice_level} ${ffmpeg} -threads 4 -i "${input}" -codec copy "${output}" 264 | if [[ $? -eq 0 ]]; then 265 | print_info "FFMPEG converted \"${input}\"" 266 | rm "${input}" 267 | echo "$(date +%Y-%m-%d" "%H:%M): ${input_file}" >> "${mkv_media_log}" 268 | mv "${output}" "$(echo "${input}"| sed 's/....$/\.mp4/')" 269 | exit 0 270 | else 271 | print_error "FFMPEG conversion of \"${input}\" failed" 272 | media_failure "${input_file}" "${media_input_directory}" 273 | die 274 | fi 275 | } 276 | 277 | convert_handbrake(){ 278 | local -r input_file="${1}" 279 | local -r media_input_directory="${2}" 280 | local -r media_output_directory="${3}" 281 | local -r input="${media_input_directory}/${input_file}" 282 | local -r output="$(echo "${media_input_directory}/${input_file}"| sed 's/....$/-converted\.mp4/')" 283 | # Remove existing "converted" files which are leftovers from previous container shutdowns 284 | if [[ -f "${output}" ]]; then 285 | rm "${output}" 286 | fi 287 | container_format='mp4' 288 | rate_tolerance_option='' 289 | bitrate='' 290 | rate_factor='' 291 | frame_rate_options='' 292 | ac3_bitrate='384' 293 | crop='0:0:0:0' 294 | readonly width="$(${mediainfo} --Inform='Video;%Width%' "${input}")" 295 | readonly height="$(${mediainfo} --Inform='Video;%Height%' "${input}")" 296 | if ((width > 1280)) || ((height > 720)); then 297 | vbv_value='17500' 298 | max_bitrate='4000' 299 | size_options='--maxWidth 1280 --maxHeight 720 ' 300 | elif ((width > 720)) || ((height > 576)); then 301 | vbv_value='17500' 302 | max_bitrate='4000' 303 | else 304 | vbv_value='12500' 305 | if ((height > 480)); then 306 | max_bitrate='1800' 307 | else 308 | max_bitrate='1500' 309 | fi 310 | fi 311 | if [ "${rate_factor}" ]; then 312 | rate_control_options="--quality ${rate_factor}" 313 | else 314 | rate_tolerance_option=':ratetol=inf' 315 | if [ "${bitrate}" ]; then 316 | if ((bitrate > vbv_value)); then 317 | bitrate="${vbv_value}" 318 | fi 319 | else 320 | readonly min_bitrate="$((max_bitrate / 2))" 321 | bitrate="$(${mediainfo} --Inform='Video;%BitRate%' "${input}")" 322 | if [ ! "$bitrate" ]; then 323 | bitrate="$(${mediainfo} --Inform='General;%OverallBitRate%' "${input}")" 324 | bitrate="$(((bitrate / 10) * 9))" 325 | fi 326 | if [ "${bitrate}" ]; then 327 | bitrate="$(((bitrate / 5) * 4))" 328 | bitrate="$((bitrate / 1000))" 329 | bitrate="$(((bitrate / 100) * 100))" 330 | if ((bitrate > max_bitrate)); then 331 | bitrate="${max_bitrate}" 332 | elif ((bitrate < min_bitrate)); then 333 | bitrate="${min_bitrate}" 334 | fi 335 | else 336 | bitrate="${min_bitrate}" 337 | fi 338 | fi 339 | rate_control_options="--vb ${bitrate}" 340 | fi 341 | frame_rate="$(${mediainfo} --Inform='Video;%FrameRate_Original%' "${input}")" 342 | if [ ! "${frame_rate}" ]; then 343 | frame_rate="$(${mediainfo} --Inform='Video;%FrameRate%' "${input}")" 344 | fi 345 | if [ ! "${frame_rate_options}" ]; then 346 | if [ "${frame_rate}" == '29.970' ]; then 347 | frame_rate_options='--rate 23.976' 348 | else 349 | frame_rate_options='--rate 30 --pfr' 350 | fi 351 | fi 352 | readonly audio_channels="$(${mediainfo} --Inform='Audio;%Channels%' "${input}" | sed 's/^\([0-9]\).*$/\1/')" 353 | readonly audio_format="$(${mediainfo} --Inform='General;%Audio_Format_List%' "${input}" | sed 's| /.*||')" 354 | if [ "${ac3_bitrate}" ] && ((audio_channels > 2)); then 355 | readonly audio_bitrate="$(${mediainfo} --Inform='Audio;%BitRate%' "${input}")" 356 | if [ "${audio_format}" == 'AC-3' ] && ((audio_bitrate <= (ac3_bitrate * 1000))); then 357 | if [ "${container_format}" == 'mp4' ]; then 358 | audio_options='--aencoder ca_aac,copy:ac3' 359 | else 360 | audio_options='--aencoder copy:ac3' 361 | fi 362 | elif [ "${container_format}" == 'mp4' ]; then 363 | audio_options="--aencoder ca_aac,ac3 --ab ,${ac3_bitrate}" 364 | else 365 | audio_options="--aencoder ac3 --ab ${ac3_bitrate}" 366 | fi 367 | elif [ "${audio_format}" == 'AAC' ]; then 368 | audio_options='--aencoder copy:aac' 369 | else 370 | audio_options='' 371 | fi 372 | if [ "${frame_rate}" == '29.970' ]; then 373 | filter_options='--detelecine' 374 | else 375 | filter_options='' 376 | fi 377 | echo "nice ${nice_level} ${handbrake} --optimize --encoder ${encode} --encopts vbv-maxrate=${vbv_value}:vbv-bufsize=${vbv_value}${rate_tolerance_option} ${rate_control_options} ${frame_rate_options} ${audio_options} --crop ${crop} ${size_options} ${filter_options} --input \"${input}\" --output \"${output}\""| tee -a "${media_handbrake_log}" 378 | nice ${nice_level} ${handbrake} --optimize --encoder ${encode} --encopts vbv-maxrate=${vbv_value}:vbv-bufsize=${vbv_value}${rate_tolerance_option} ${rate_control_options} ${frame_rate_options} ${audio_options} --crop ${crop} ${size_options} ${filter_options} --input "${input}" --output "${output}" 379 | if [[ $? -eq 0 ]]; then 380 | rm "${input}" > /dev/null 2>&1 381 | echo "$(date +%Y-%m-%d" "%H:%M): ${input_file}" >> "${mp4_media_log}" 382 | mv "${output}" "${media_output_directory}/${input_file}" 383 | else 384 | media_failure "${input_file}" "${media_input_directory}" 385 | die 386 | fi 387 | } 388 | 389 | container_setup(){ 390 | add_user "${puid}" "${pgid}" 391 | set_timezone "${tz}" 392 | check_mount 393 | for directory in log_dir media_base movie_add tv_add movie_convert tv_convert movie_import tv_import; do 394 | create_directory "${!directory}" 395 | done 396 | setup_logfile 397 | touch /var/tmp/.media-converter.uptime 398 | print_priority_info 399 | } 400 | 401 | media_converter_loop(){ 402 | while true; do 403 | sudo -u media bash -c "source /opt/functions && move_media"; \ 404 | sudo -u media bash -c "/usr/local/bin/convert_media \"${media_server}\" \"${encode}\" \"TV\""; \ 405 | sudo -u media bash -c "/usr/local/bin/convert_media \"${media_server}\" \"${encode}\" \"MOVIE\""; \ 406 | sleep 30 407 | done 408 | } 409 | --------------------------------------------------------------------------------