website/content/posts/jellyfin-language-specific-library.md
Filip Rojek cb406932cc
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 16s
Build Zola Website / build (pull_request) Successful in 23s
New post: Creating a Language-Specific Jellyfin Library
2024-12-14 19:50:57 +01:00

7.3 KiB
Raw Blame History

+++ title = "Creating a Language-Specific Jellyfin Library" date = 2024-12-14 description = "How to set up a Jellyfin library for language-specific content" +++

Managing a multilingual media library can be a challenge, especially if you want to share specific language content with others. In my case, I have a large collection of movies and TV shows in both Czech and English. To help my parents enjoy only Czech-language content, I decided to create a dedicated Jellyfin library for it. Since Jellyfin doesnt natively support filtering libraries by language, I built a custom solution using Bash scripting and Docker.

The Problem

My media library has the following folder structure for movies:

movies/
├── Movie name (Year of release)
│   ├── Movie name resolution.mkv
│   └── Movie name resolution.nfo

And for series:

series/
├── Series name
│   └── Season 1
│       ├── Series name - S01E01 - Pilot WEBRip-1080p.mkv
│       ├── Series name - S01E01 - Pilot WEBRip-1080p.cs.srt
│       └── Series name - S01E01 - Pilot WEBRip-1080p.en.srt

For movies, each film is stored in its own folder. For series, episodes are nested within season folders, under the parent series folder. Jellyfin requires each movie or series to reside in its own directory, which means symlinks must point to the parent folder rather than the media files themselves.

Additionally, I needed to differentiate between movies and series when creating symlinks. For movies, the parent directory is the movie folder, but for series, the symlink should point to the series root folder (not the season folder). Heres how I solved it.

Custom Script for Language Filtering

The following script processes the media directories to identify content with Czech audio tracks and creates symlinks to organize them into separate cz_movies and cz_series directories.

#!/bin/bash

# Define directories and language codes
SOURCE_DIR_MOVIES="/media/movies"
TARGET_DIR_MOVIES="/media/cz_movies"
SOURCE_DIR_SERIES="/media/series"
TARGET_DIR_SERIES="/media/cz_series"
LANGUAGES_TO_CHECK=("cze" "cz" "Czech" "czech" "česky" "cs" "ces")
FFPROBE="/usr/lib/jellyfin-ffmpeg/ffprobe"

# List of file extensions to skip
SKIP_EXTENSIONS=("nfo" "srt" "sh")

# Function to process a source directory
process_directory() {
    local SOURCE_DIR="$1"
    local TARGET_DIR="$2"
    local IS_SERIES="$3"

    # Loop through all files in SOURCE_DIR and subdirectories
    find "$SOURCE_DIR" -type f | while read FILE; do
        # Get the file extension
        EXTENSION="${FILE##*.}"

        # Check if the file extension is in the skip list
        if [[ " ${SKIP_EXTENSIONS[@]} " =~ " $EXTENSION " ]]; then
            # Skip the file if it's in the skip list
            continue
        fi

        echo "Processing $FILE"

        # Get the languages from the current file
        LANGUAGES=($($FFPROBE -v error -show_entries stream=codec_type:stream_tags=language:stream=codec_name -of default=noprint_wrappers=1:nokey=1 "$FILE" | tr '\n' ' ' | grep -o 'audio [^ ]*' | awk '{print $2}'))

        # Check if any of the languages in LANGUAGES_TO_CHECK exist in LANGUAGES
        FOUND=false
        for LANG_TO_CHECK in "${LANGUAGES_TO_CHECK[@]}"; do
            if [[ " ${LANGUAGES[@]} " =~ " $LANG_TO_CHECK " ]]; then
                FOUND=true
                break
            fi
        done

        if $FOUND; then
            echo "Found a matching language (${LANGUAGES_TO_CHECK[@]}) in the list"
            # Determine the symlink target based on whether this is a series or a movie
            if [ "$IS_SERIES" = true ]; then
                # For series, link the <Series name> directory
                PARENT_DIR=$(dirname "$(dirname "$FILE")")
            else
                # For movies, link the <Movie name> directory
                PARENT_DIR=$(dirname "$FILE")
            fi

            # Create a symlink to the parent directory in TARGET_DIR
            ln -snf "$PARENT_DIR" "$TARGET_DIR/$(basename "$PARENT_DIR")"
            echo "Symlink \"$PARENT_DIR\" \"$TARGET_DIR/$(basename "$PARENT_DIR")\""
            echo "Symlink created for $PARENT_DIR"
        else
            echo "Skipping $FILE"
        fi

    done
}

# Process movies and series directories
process_directory "$SOURCE_DIR_MOVIES" "$TARGET_DIR_MOVIES" false
process_directory "$SOURCE_DIR_SERIES" "$TARGET_DIR_SERIES" true

Automating the Script with Docker and Cron

To automate the script, I run it hourly using a minimal Docker container with cron installed. Instead of modifying the Jellyfin Docker image, I created a separate Alpine-based container to handle the job.

Dockerfile for the Cron Container

FROM alpine:latest

RUN apk update && \
    apk add --no-cache docker && \
    rm -rf /var/cache/apk/*

CMD ["sh"]

Docker-Compose Setup

Heres the updated docker-compose.yml that includes the Jellyfin service and the cron container:

services:
  jellyfin:
    image: jellyfin/jellyfin:latest
    container_name: jellyfin

    # ... your jellyfin docker compose ...

    volumes:
      - ./czech-lib-cron.sh:/czech-lib-cron.sh:ro # the custom library script
      - /mnt/media/torrent:/media:ro # your media folder
      - /mnt/media/torrent/cz_movies:/media/cz_movies # the newely created language specific library folder 
      - /mnt/media/torrent/cz_series:/media/cz_series # the newely created language specific library folder 

  jellyfin-cron:
    build:
      context: ../docker-cron
      dockerfile: Dockerfile
    container_name: jellyfin-cron
    # Run the custom library script every hour at 45 minutes past
    command: >
      /bin/sh -c "
      echo '45 * * * * docker exec -u root jellyfin bash -c \"/czech-lib-cron.sh\"' > /etc/crontabs/root && 
      crond -f -l 2"      
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock  # Needed for `docker exec`
    networks:
      - default

Bonus: Fix Missing Audio Language Metadata

Some files in my library lacked proper audio language metadata, causing them to be missed by the script. To fix this, I wrote another script to add the correct metadata:

#!/bin/bash

# Root directory (current working directory)
ROOT_DIR="$(pwd)"

# Function to process each video file
process_file() {
  local input_file="$1"
  local temp_file="${input_file%.avi}_temp.avi"

  echo "Processing: $input_file"

  # Add metadata to the audio track and overwrite the original file
  ffmpeg -i "$input_file" -map 0 -c copy -metadata:s:a:0 language=cze "$temp_file"

  if [[ $? -eq 0 ]]; then
    mv "$temp_file" "$input_file"
    echo "Successfully updated: $input_file"
  else
    echo "Error updating: $input_file"
    rm -f "$temp_file" # Remove temporary file on failure
  fi
}

# Export function for use in find's -exec
export -f process_file

# Find all .avi files and process them
find "$ROOT_DIR" -type f -name "*.avi" -exec bash -c 'process_file "$0"' {} \;

This script re-encodes files to include the missing language metadata. It defaults to .avi files, but you can adjust it as needed.

Conclusion

With this setup, you can create language-specific libraries in Jellyfin. Make sure to add the /media/cz_movies and /media/cz_series folders as paths for new libraries in the Jellyfin dashboard settings. After adding these libraries, simply scan them to see your filtered content. Happy watching!