Version of this script with latests improvements, still rockin' in 2026 ! (note : I removed transmission section caus' I don't use it, feel free to add it again). #!/bin/bash
# This script gets playing sessions from Plex, and tells rsync to copy them from array to cache drive.
# It also cleans oldest modified files by rsyncing them back to array (in case of modification).
# Hardlink are preserved
#
# By Reynald mailto:
[email protected]
# Now works with plex v1.40 or later
# WARNING this script must be scheduled carefully one time every 6 minutes max (10 minutes advised), execute it more frequently can damage your SSD
# Correction of file permissions by Reynald
# By Quinto added analyze of media by plex after syncing to prevent wrong media duration (2hrs bug)
# By Quinto Now work with multiple parts movie and with multiple medias movie (eg Same movie in 1080p and 4k)
# v.0.6.2
# settings
{
#Plex
# put here the corresponding docker name of your plex App egg binhex-plex, Plex-Media-Server, etc
PLEX_DOCKER="Plex-Media-Server"
PLEX_TOKEN="xxxxxxxxxxxxxxxxx"
PLEX_HOST="192.168.0.240:32400"
PLEX_MAX_CACHED_SESSIONS=16
PLEX_CONTAINER_PATH="video_data"
#Rsync path & options
STORAGE_PATH="/mnt/user0/video_data/"
CACHE_PATH="/mnt/plex_smart_cache/video_data/"
CACHE_MIN_FREE_SPACE_PCT="94"
CACHE_MAX_FREE_SPACE_PCT="60"
#Parameters
LOG_MAX_SIZE=5000000
NOISY_HOUR_START=
NOISY_HOUR_STOP=
# if you're not using Youtube-dl agent for plex don't touch this line, it must only be changed when you're using yt-dl custom agent
YOUTUBE_DL_AGENT="com.plexapp.agents.youtube-dl"
#Options (set to true or false)
DRY_RUN=false
WRITE_ON_STORAGE=false
#number of episodes to cache
PLEX_CACHE_NB_EPISODES=6
PLEX_CACHE_SEASON_TILL_END=false
VERBOSE=0 #0=Error; 1=Info; 2=More_Info; 3=Debug
}
##### No modification below this line #####
CACHE_DISK=$(df -lah "$CACHE_PATH" | awk 'FNR == 2 {print $1}' | tr -d '\n')
sys_checks() {
# check if plex docker is running
plex_running=`docker inspect -f '{{.State.Running}}' $PLEX_DOCKER`
if [[ ! $plex_running ]]
then
echo "Error: Plex docker is not running"
rm /var/lock/smart-cache_plex_transmission
exit 1
fi
# lock
if [[ -f /var/lock/smart-cache_plex_transmission ]]
then
echo "Error: Script already running"
rm /var/lock/smart-cache_plex_transmission
exit 1
else
touch /var/lock/smart-cache_plex_transmission
[[ $VERBOSE -ge 2 ]] && echo "Welcome to $0"
fi
# check that path are mounted
if [[ ! -d $STORAGE_PATH ]] || [[ ! -d $CACHE_PATH ]];
then
echo "Error: Paths are not accessibles"
rm /var/lock/smart-cache_plex_transmission
exit 1
fi
# cut log
LOG_FILE=$(echo $0 | sed 's|\/script|\/log.txt|')
echo "log= $LOG_FILE"
LOG_SIZE=$(stat -c %s "$LOG_FILE")
[[ $VERBOSE -ge 1 ]] && echo "Info: Log size is $LOG_SIZE"
if [[ $LOG_SIZE -ge $LOG_MAX_SIZE ]]
then
[[ $VERBOSE -ge 1 ]] && echo "Info: Emptying log file"
echo "" > "$LOG_FILE"
fi
[[ $VERBOSE -ge 1 ]] && echo ""
}
#######################
# Transfers functions #
#######################
noisy_hours() {
# return 0 if time in noisy hour range
if [[ $(date '+%-H') -ge $NOISY_HOUR_START ]] && [[ $(date +%-H) -le $NOISY_HOUR_STOP ]]
then
return 0
else
return 1
fi
}
rsync_transfer() {
# get files and path
SOURCE_FILE=$1
DEST_FILE=$2
SOURCE_PATH=$3
DEST_PATH=$4
PLEX_ANALYZE_STRING=$5
RS_OPTIONS=$6
[[ $VERBOSE -ge 3 ]] && echo " --- Debug:Rsync_transfer function parameters:"
[[ $VERBOSE -ge 3 ]] && echo " ---- Debug: Source file: $SOURCE_FILE"
[[ $VERBOSE -ge 3 ]] && echo " ---- Debug: Dest. file: $DEST_FILE"
[[ $VERBOSE -ge 3 ]] && echo " ---- Debug: Source path: $SOURCE_PATH"
[[ $VERBOSE -ge 3 ]] && echo " ---- Debug: Dest. path: $DEST_PATH"
[[ $VERBOSE -ge 3 ]] && echo " ---- Debug: Plex Analyze string: $PLEX_ANALYZE_STRING"
[[ $VERBOSE -ge 3 ]] && echo " ---- Debug: Options : $RS_OPTIONS"
# check if original file exist
if [[ ! -f "${SOURCE_FILE}" ]] && [[ ! -f "${DEST_FILE}" ]]
then
echo " --- Error: Files:"
echo " ${SOURCE_FILE}"
echo " ${DEST_FILE}"
echo " does not exist"
return 1
elif [[ "${DEST_FILE}" = "${DEST_PATH}" ]] || [[ "${SOURCE_FILE}" = "${SOURCE_PATH}" ]]
then
echo " --- Error: Cannot sync root path!"
return 1
elif [[ ! -f "${SOURCE_FILE}" ]] && [[ "${DEST_PATH}" = "${CACHE_PATH}" ]] && [[ -f "${DEST_FILE}" ]]
then
if noisy_hours
then
if [[ ! WRITE_ON_STORAGE ]]
then
[[ $VERBOSE -ge 2 ]] && echo " --- Info: File is on cache only. Inside noisy hours, sending to storage"
rsync_transfer "${DEST_FILE}" "${SOURCE_FILE}" "${DEST_PATH}" "${SOURCE_PATH}" "${PLEX_ANALYZE_STRING}"
fi
else
[[ $VERBOSE -ge 2 ]] && echo " --- Warning: File is on cache only. Outside of noisy hours, doing nothing"
fi
return
elif [[ -f "${DEST_FILE}" ]] && [[ "${DEST_PATH}" = "${CACHE_PATH}" ]]
then
[[ $VERBOSE -ge 1 ]] && echo " --- Info: File already cached"
return
fi
# get dir
SOURCE_DIR=$(dirname "${SOURCE_FILE}")
DEST_DIR=$(dirname "${DEST_FILE}")
if ! $DRY_RUN
then
# sync file
sudo mkdir -p -m u-x,go-rwx,go+u,ugo+X "${DEST_DIR}"
[[ $VERBOSE -ge 1 ]] && echo " --- Info: Syncing ${SOURCE_FILE} to ${DEST_FILE}"
sudo nice -n 13 ionice -c 3 /usr/bin/rsync --bwlimit=190000 -aHq "${SOURCE_FILE}" "${DEST_FILE}"
if [[ ! $? -eq 0 ]]
then
echo " --- Error: cannot rsync ${SOURCE_FILE}"
echo " to ${DEST_FILE}"
return 1
else
[[ $VERBOSE -ge 1 ]] && echo " --- Info: Syncing succeeded - refreshing plex media"
sleep 0.8
curl --limit-rate 400k --silent "${PLEX_ANALYZE_STRING}"
fi
# remove original file if requested
if [[ "${RS_OPTIONS}" = "--remove-source-files" ]] && $RSYNC_RESULT
then
[[ $VERBOSE -ge 2 ]] && echo " --- Info: Delete ${SOURCE_FILE}"
sudo nice -n 13 ionice -c 3 /usr/bin/rm "${SOURCE_FILE}"
fi
else
echo " --- Running in DRY RUN mode : Files aren't really copied, doing nothing"
fi
}
################
# Plex Caching #
################
plex_cache() {
# get Plex sessions
STATUS_SESSIONS=$(nice -n 10 curl --limit-rate 400k --silent http://${PLEX_HOST}/status/sessions -H "X-Plex-Token: $PLEX_TOKEN")
if [[ -z $STATUS_SESSIONS ]];
then
echo "Error: Cannot connect to plex"
return 1
fi
NB_SESSIONS=$(echo $STATUS_SESSIONS | xmllint --xpath 'string(//MediaContainer/@size)' -)
echo "----------------------------"
echo "$NB_SESSIONS active(s) plex session(s):"
echo "----------------------------"
# for each session
if [[ $NB_SESSIONS -gt $PLEX_MAX_CACHED_SESSIONS ]]
then
NB_SESSIONS=$PLEX_MAX_CACHED_SESSIONS
echo "Warning: Caching is limited to $PLEX_MAX_CACHED_SESSIONS plex sessions (user setting)"
fi
for i in `seq $NB_SESSIONS`
do
# get title
ID=$(echo $STATUS_SESSIONS | xmllint --xpath 'string(//MediaContainer/Video['$i']/@ratingKey)' -)
TYPE=$(echo $STATUS_SESSIONS | xmllint --xpath 'string(//MediaContainer/Video['$i']/@type)' -)
MKEY=$(echo $STATUS_SESSIONS | xmllint --xpath 'string(//MediaContainer/Video['$i']/@key)' -)
# eventually get serie info
if [[ $TYPE = "episode" ]]
then
TYPE="Serie"
GRANDPARENTTITLE=$(echo $STATUS_SESSIONS | xmllint --xpath 'string(//MediaContainer/Video['$i']/@grandparentTitle)' -)
SEASON=$(echo $STATUS_SESSIONS | xmllint --xpath 'string(//MediaContainer/Video['$i']/@parentIndex)' -)
TITLE=$(echo $STATUS_SESSIONS | xmllint --xpath 'string(//MediaContainer/Video['$i']/@title)' -)
EPISODE=$(echo $STATUS_SESSIONS | xmllint --xpath 'string(//MediaContainer/Video['$i']/@index)' -)
PARENT_ID=$(echo $STATUS_SESSIONS | xmllint --xpath 'string(//MediaContainer/Video['$i']/@parentRatingKey)' -)
PLEX_FILE_SESS_TVSHOW=$(nice -n 10 curl --limit-rate 400k --silent http://${PLEX_HOST}/library/metadata/$PARENT_ID/children)
PARENT_NB_EPISODES=$(echo $PLEX_FILE_SESS_TVSHOW | xmllint --xpath 'string(//MediaContainer/@size)' -)
PARENT_START_EPISODE=$(echo $PLEX_FILE_SESS_TVSHOW | xmllint --xpath 'string(//MediaContainer/Video[1]/@index)' -)
SECTION_ID=$(curl --limit-rate 400k --silent http://${PLEX_HOST}/library/metadata/$PARENT_ID | xmllint --xpath 'string(//MediaContainer/@librarySectionID)' -)
SECTION_AGENT=$(curl --limit-rate 400k --silent http://${PLEX_HOST}/library/sections?X-Plex-Token= | xmllint --xpath 'string(//MediaContainer/Directory[@key='$SECTION_ID']/@agent)' -)
# update nb file to cache
START_FILE="$EPISODE"
if [[ "${SECTION_AGENT}" = "${YOUTUBE_DL_AGENT}" ]]
then
TYPE="Youtube"
NB_FILES=1
else
if [[ $PLEX_CACHE_NB_EPISODES -gt 0 ]];
then
NB_FILES=$(( ($EPISODE + $PLEX_CACHE_NB_EPISODES) < $PARENT_NB_EPISODES ? ($EPISODE + $PLEX_CACHE_NB_EPISODES) : $PARENT_NB_EPISODES ))
else
$PLEX_CACHE_NEXT_EPISODE && NB_FILES=$(( $EPISODE + 1))
fi
$PLEX_CACHE_SEASON_TILL_END && NB_FILES=$(( $PARENT_NB_EPISODES ))
fi
TITLE="$TYPE: ${GRANDPARENTTITLE} Season ${SEASON} - Episode ${EPISODE}/${PARENT_NB_EPISODES}: $TITLE"
elif [[ $TYPE = "movie" ]]
then
TYPE="Movie"
TITLE="$(echo $STATUS_SESSIONS | xmllint --xpath 'string(//MediaContainer/Video['$i']/@title)' -)"
TITLE="$TYPE: $TITLE"
PLEX_FILE_SESS_MOVIE=$(nice -n 10 curl --limit-rate 400k --silent http://${PLEX_HOST}/library/metadata/$ID)
else
TYPE="Audio"
TITLE="track caching not implemented"
TITLE="$TYPE: $TITLE"
START_FILE=0
NB_FILES=0
fi
echo " - $i / $NB_SESSIONS: $TITLE"
NB_MEDIAS=$(echo $PLEX_FILE_SESS_MOVIE | xmllint --xpath "count(//Media)" -)
NB_FILES=$(echo $PLEX_FILE_SESS_MOVIE | xmllint --xpath "count(//Part)" -)
[[ $VERBOSE -ge 2 ]] && echo " -- Info: Number of medias :${NB_MEDIAS}"
[[ $VERBOSE -ge 2 ]] && echo " -- Info: Number of files :${NB_FILES}"
START_FILE=1
for j in `seq $START_FILE $NB_FILES`
do
# get file path
if [[ $TYPE = "Audio" ]]
then
[[ $VERBOSE -ge 2 ]] && echo " -- Info: Skipping"
else
if [[ $TYPE != "Movie" ]]
then
if [[ $TYPE = "Youtube" ]]
then
# media is a youtube episode
# [[ $VERBOSE -ge 2 ]] && echo " -- Info: Skipping"
PLEX_FILE=$(echo "${PLEX_FILE_SESS_TVSHOW}" | xmllint --xpath 'string(//MediaContainer/Video['$(($PARENT_START_EPISODE - $EPISODE +1))']/Media/Part/@file)' -)
ID=$(echo "${PLEX_FILE_SESS_TVSHOW}" | xmllint --xpath 'string(//MediaContainer/Video['$(($PARENT_START_EPISODE - $EPISODE +1))']/@ratingKey)' -)
else
# media is tv show
PLEX_FILE=$(echo "${PLEX_FILE_SESS_TVSHOW}" | xmllint --xpath 'string(//MediaContainer/Video['$(($j - $PARENT_START_EPISODE + 1))']/Media/Part/@file)' -)
ID=$(echo "${PLEX_FILE_SESS_TVSHOW}" | xmllint --xpath 'string(//MediaContainer/Video['$(($j - $PARENT_START_EPISODE + 1))']/@ratingKey)' -)
fi
m=$PARENT_NB_EPISODES
else
# media is a movie
if [[ $NB_FILES -gt 1 ]]
then
if [[ $NB_FILES -gt $NB_MEDIAS ]]
then
# multiple parts movie (part1, part2, etc)
PLEX_FILE=$(echo "${PLEX_FILE_SESS_MOVIE}" | xmllint --xpath 'string(//MediaContainer/Video/Media/Part['$j']/@file)' -)
else
# multiple medias movie (4k, 1080p, 720p, etc)
PLEX_FILE=$(echo "${PLEX_FILE_SESS_MOVIE}" | xmllint --xpath 'string(//MediaContainer/Video/Media['$j']/Part/@file)' -)
fi
else
# movie in single part / format
PLEX_FILE=$(echo "${PLEX_FILE_SESS_MOVIE}" | xmllint --xpath 'string(//MediaContainer/Video/Media/Part/@file)' -)
fi
echo "${PLEX_FILE}"
m=$NB_FILES
fi
PLEX_ANALYZE="http://${PLEX_HOST}/library/metadata/$ID/analyze?X-Plex-Token: ${PLEX_TOKEN}"
FILE_TO_CACHE="$(sed -e 's|\"\"|\/|g' -e 's/%/\%/g' <<<${PLEX_FILE} | sed 's|\"||g' | sed 's|\/'${PLEX_CONTAINER_PATH}'\/||')"
[[ $VERBOSE -ge 2 ]] && echo " -- Info: Streaming File $j/$m: ${FILE_TO_CACHE}"
STORAGE_FILE="${STORAGE_PATH}${FILE_TO_CACHE}"
CACHE_FILE="${CACHE_PATH}${FILE_TO_CACHE}"
# and send to rsync
rsync_transfer "${STORAGE_FILE}" "${CACHE_FILE}" "${STORAGE_PATH}" "${CACHE_PATH}" "${PLEX_ANALYZE}"
fi
sleep .1
done
sleep .1
done
sleep .1
}
####################
# Delete old files #
####################
cleanup() {
# get free space
sa=$(df -lah | grep $CACHE_DISK | awk '{ printf "%d", $5 }')
sb=$CACHE_MIN_FREE_SPACE_PCT
echo "---------------------"
echo "Cache disk usage: ${sa}%"
echo "---------------------"
if [[ "$sa" -ge "$sb" ]];
then
echo "$sa% space used, quota is $sb%, cleaning"
[[ $VERBOSE -ge 1 ]] && echo "Info: Scanning files..."
# get oldest accessed files
find "${CACHE_PATH}" -type f -printf "%C@ %p\n" | sort -n | sed "s|`echo ${CACHE_PATH}`|%|g" | cut -d'%' -f2 | while read FILE_TO_CLEAN
do
# loop start: get free space again
sa=$(df -lah | grep $CACHE_DISK | awk '{ printf "%d", $5 }')
sb=$CACHE_MAX_FREE_SPACE_PCT
# if free space not enough
if [[ "$sa" -ge "$sb" ]];
then
[[ $VERBOSE -ge 1 ]] && echo " - Info: $sa% space used, target $sb%, uncaching $FILE_TO_CLEAN"
STORAGE_FILE="${STORAGE_PATH}${FILE_TO_CLEAN}"
CACHE_FILE="${CACHE_PATH}${FILE_TO_CLEAN}"
# sync back cache to storage
rsync_transfer "${CACHE_FILE}" "${STORAGE_FILE}" "${CACHE_PATH}" "${STORAGE_PATH}" "--remove-source-files"
fi
# loop
done
fi
sa=$(df -lah | grep $CACHE_DISK | awk '{ printf "%d", $5 }')
sb=$CACHE_MIN_FREE_SPACE_PCT
[[ $VERBOSE -ge 1 ]] && echo " - Info: $sa% space used, quota is $sb%, nothing to do"
# prune empty directories from source dir
[[ $VERBOSE -ge 2 ]] && echo " -- Info: Cleaning empty directories..."
find "${CACHE_PATH}" -type d -not -path '*/\.*' -empty -prune -exec rmdir --ignore-fail-on-non-empty {} \;
[[ $VERBOSE -ge 1 ]] && echo ""
}
sys_checks
plex_cache
cleanup
rm /var/lock/smart-cache_plex_transmission
exit 0