| 1 | #!/bin/bash
|
| 2 | ###############################################################################
|
| 3 | # dlyt.sh
|
| 4 | #
|
| 5 | # YouTube → Jellyfin Downloader & Organizer
|
| 6 | #
|
| 7 | # Downloads recent YouTube videos from channels listed in channels.txt,
|
| 8 | # organizes them into a Jellyfin-friendly folder structure, generates NFO files,
|
| 9 | # converts thumbnails from WEBP to JPG, and cleans up unnecessary files.
|
| 10 | #
|
| 11 | # Features:
|
| 12 | # - Downloads up to 5 videos per channel (configurable via --playlist-end)
|
| 13 | # - Skips live, upcoming, long (>2h) videos, or titles containing "WAN"
|
| 14 | # - Sorts episodes by upload_date
|
| 15 | # - Creates Season 01 and Specials (shorts)
|
| 16 | # - Generates tvshow.nfo with channel description
|
| 17 | # - Converts thumbnails from WEBP → JPG
|
| 18 | # - Cleans up info.json and description files after NFO creation
|
| 19 | # - Removes orphaned or leftover files
|
| 20 | # - Changes ownership to jellyfin for proper library access
|
| 21 | #
|
| 22 | # Dependencies:
|
| 23 | # - yt-dlp
|
| 24 | # - jq
|
| 25 | # - ImageMagick (magick command)
|
| 26 | # - Bash ≥4
|
| 27 | ###############################################################################
|
| 28 |
|
| 29 | set -euo pipefail # Abort on errors, unset variables are errors, pipeline failures propagate
|
| 30 |
|
| 31 | # BASE DIRECTORIES
|
| 32 | BASE="/dir/to/folder" # Root for all downloads
|
| 33 | YTDLP="$BASE/yt-dlp" # Folder for downloads
|
| 34 | CHANNELS="$BASE/channels.txt" # List of channels
|
| 35 |
|
| 36 | mkdir -p "$YTDLP" # Create download folder if missing
|
| 37 |
|
| 38 | sudo chown -R user:user "${YTDLP}" # Temporarily give ownership to the user to allow writing
|
| 39 |
|
| 40 |
|
| 41 |
|
| 42 | echo "===== yt-dlp download ====="
|
| 43 |
|
| 44 | # Download videos with yt-dlp
|
| 45 | yt-dlp \
|
| 46 | --ignore-errors \
|
| 47 | --no-overwrites \
|
| 48 | --continue \
|
| 49 | --restrict-filenames \
|
| 50 | --windows-filenames \
|
| 51 | --playlist-end 5 \
|
| 52 | \
|
| 53 | --dateafter now-10days \
|
| 54 | \
|
| 55 | --match-filter "!is_live & live_status!=is_upcoming & duration<7200 & title!*=WAN" \
|
| 56 | \
|
| 57 | --format "(bestvideo[ext=mp4][height<=1080]/bestvideo[height<=1080])+(bestaudio[ext=m4a]/bestaudio)/best[ext=mp4]/best" \
|
| 58 | --merge-output-format mp4 \
|
| 59 | \
|
| 60 | --write-info-json \
|
| 61 | --write-description \
|
| 62 | --write-thumbnail \
|
| 63 | --embed-chapters \
|
| 64 | --sponsorblock-mark all,-preview,-filler,-interaction \
|
| 65 | --download-archive "$YTDLP/downloaded.txt" \
|
| 66 | \
|
| 67 | --output "$YTDLP/%(uploader)s/%(title)s [%(id)s].%(ext)s" \
|
| 68 | \
|
| 69 | -a "$CHANNELS" || true
|
| 70 |
|
| 71 |
|
| 72 |
|
| 73 | echo "===== Organizing channels ====="
|
| 74 |
|
| 75 | # Folder layout for each channel:
|
| 76 | # SHOW/ <- Root folder for channel (e.g., "SciManDan")
|
| 77 | # ├─ Season 01/ <- Regular episodes (longer than 240s)
|
| 78 | # │ ├─ S01E01 - Title1 [ID].mp4
|
| 79 | # │ ├─ S01E02 - Title2 [ID].mp4
|
| 80 | # │ ├─ ...
|
| 81 | # │ ├─ S01E01 - Title1 [ID].webp <- optional, converted later to .jpg
|
| 82 | # │ └─ S01E02 - Title2 [ID].webp
|
| 83 | # ├─ Specials/ <- Shorts (<241s (=4min))
|
| 84 | # │ ├─ S00E01 - Short1 [ID].mp4
|
| 85 | # │ ├─ S00E02 - Short2 [ID].mp4
|
| 86 | # │ └─ ...
|
| 87 | # ├─ tvshow.nfo <- Generated channel NFO
|
| 88 | # ├─ poster.jpg <- First JPG in channel, copied as poster
|
| 89 | # └─ other leftover files <- cleaned up by the script
|
| 90 |
|
| 91 | # Iterate over each channel folder
|
| 92 | find "$YTDLP" -mindepth 1 -maxdepth 1 -type d | while read -r SHOW; do
|
| 93 | mkdir -p "$SHOW/Season 01" "$SHOW/Specials" # Create Season/Specials folders
|
| 94 |
|
| 95 | ep1=1 # Season 01 episode counter
|
| 96 | ep0=1 # Specials (shorts) episode counter
|
| 97 |
|
| 98 | # Sort episodes by upload_date: get date|jsonfile, sort by date, then cut jsonfile
|
| 99 | find "$SHOW" -maxdepth 1 -name "*.info.json" | while read -r JSON; do
|
| 100 | DATE=$(jq -r '.upload_date // "00000000"' "$JSON") # Use default 00000000 if missing
|
| 101 | echo "$DATE|$JSON" # Format: YYYYMMDD|jsonfile
|
| 102 | done | sort | cut -d'|' -f2 | while read -r JSON; do
|
| 103 | # Explanation of pipe:
|
| 104 | # 1. `jq` extracts upload_date from each .info.json
|
| 105 | # 2. `echo "$DATE|$JSON"` pairs date with filename
|
| 106 | # 3. `sort` sorts alphabetically → chronological because YYYYMMDD
|
| 107 | # 4. `cut -d'|' -f2` returns only the filename, in sorted order
|
| 108 | BASEFILE="${JSON%.info.json}" # Base filename without extension
|
| 109 | MP4="$BASEFILE.mp4" # Corresponding video file
|
| 110 | [ ! -f "$MP4" ] && continue # Skip if video missing
|
| 111 |
|
| 112 | TITLE=$(jq -r '.title' "$JSON") # Video title
|
| 113 | DESC=$(jq -r '.description // ""' "$JSON") # Video description (empty if missing)
|
| 114 | DATE=$(jq -r '.upload_date' "$JSON") # Upload date
|
| 115 | ID=$(jq -r '.id' "$JSON") # YouTube video ID
|
| 116 | DURATION=$(jq -r '.duration // 0' "$JSON") # Duration in seconds
|
| 117 |
|
| 118 | AIRED="${DATE:0:4}-${DATE:4:2}-${DATE:6:2}" # Convert YYYYMMDD → YYYY-MM-DD
|
| 119 |
|
| 120 | # Determine Season / Specials based on duration
|
| 121 | if [ "$DURATION" -lt 241 ]; then
|
| 122 | SEASON=0
|
| 123 | EP=$(printf "%02d" "$ep0") # Zero-padded episode
|
| 124 | SAFE_TITLE=$(echo "$TITLE" | cut -c1-120) # Truncate long titles to 120 chars
|
| 125 | DEST="$SHOW/Specials/S00E$EP - $SAFE_TITLE [$ID]"
|
| 126 | ep0=$((ep0+1)) # Increment specials counter
|
| 127 | else
|
| 128 | SEASON=1
|
| 129 | EP=$(printf "%02d" "$ep1")
|
| 130 | SAFE_TITLE=$(echo "$TITLE" | cut -c1-120)
|
| 131 | DEST="$SHOW/Season 01/S01E$EP - $SAFE_TITLE [$ID]"
|
| 132 | ep1=$((ep1+1)) # Increment season counter
|
| 133 | fi
|
| 134 |
|
| 135 | mkdir -p "$(dirname "$DEST")" # Ensure destination folder exists
|
| 136 |
|
| 137 | mv "$MP4" "$DEST.mp4" # Move video to organized folder
|
| 138 |
|
| 139 | # Move WEBP thumbnail to match episode name
|
| 140 | if [ -f "$BASEFILE.webp" ]; then
|
| 141 | mv "$BASEFILE.webp" "$DEST.webp"
|
| 142 | fi
|
| 143 |
|
| 144 | # Create episode NFO
|
| 145 | cat > "$DEST.nfo" <<EOF
|
| 146 | # Mapping of episode files:
|
| 147 | # For each episode DEST:
|
| 148 | # DEST.mp4 <- main video
|
| 149 | # DEST.webp <- original thumbnail (moved)
|
| 150 | # DEST.jpg <- converted thumbnail via ImageMagick
|
| 151 | # DEST.nfo <- episode metadata
|
| 152 | #
|
| 153 | # This ensures Jellyfin recognizes the episode with proper thumbnail and metadata.
|
| 154 |
|
| 155 | <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
| 156 | <episodedetails>
|
| 157 | <title>$TITLE</title>
|
| 158 | <season>$SEASON</season>
|
| 159 | <episode>$((10#$EP))</episode> # Convert to decimal, handle leading zeros
|
| 160 | <airikred>$AIRED</airikred>
|
| 161 | <premiered>$AIRED</premiered>
|
| 162 | <plot><![CDATA[$DESC]]></plot>
|
| 163 | <uniqueid type="youtube">$ID</uniqueid>
|
| 164 | </episodedetails>
|
| 165 | EOF
|
| 166 |
|
| 167 | rm -f "$BASEFILE.info.json" "$BASEFILE.description" # Cleanup metadata
|
| 168 | done
|
| 169 |
|
| 170 |
|
| 171 |
|
| 172 | echo "===== Generating tvshow.nfo for $(basename "$SHOW") ====="
|
| 173 |
|
| 174 | # tvshow.nfo & poster.jpg:
|
| 175 | # - tvshow.nfo contains channel title, studio, and channel description (plot)
|
| 176 | # - poster.jpg is copied from the first .jpg found in channel (episode thumbnail)
|
| 177 | #
|
| 178 | # Jellyfin reads this for series overview in the library.
|
| 179 |
|
| 180 | if [ ! -f "$SHOW/tvshow.nfo" ]; then
|
| 181 |
|
| 182 | # Attempt to grab channel description from first info.json
|
| 183 | CHANNEL_JSON=$(find "$SHOW" -maxdepth 1 -name "*[[]*.info.json" | head -n 1)
|
| 184 | if [ -n "$CHANNEL_JSON" ]; then
|
| 185 | SHOW_DESC=$(jq -r '.description // ""' "$CHANNEL_JSON")
|
| 186 | else
|
| 187 | SHOW_DESC=""
|
| 188 | fi
|
| 189 |
|
| 190 | cat > "$SHOW/tvshow.nfo" <<EOF
|
| 191 | <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
| 192 | <tvshow>
|
| 193 | <title>$(basename "$SHOW")</title>
|
| 194 | <studio>YouTube</studio>
|
| 195 | <plot><![CDATA[$SHOW_DESC]]></plot>
|
| 196 | </tvshow>
|
| 197 | EOF
|
| 198 | fi
|
| 199 |
|
| 200 | # Ensure poster.jpg exists for the channel
|
| 201 | if [ ! -f "$SHOW/poster.jpg" ]; then
|
| 202 | THUMB=$(find "$SHOW" -type f -iname "*.jpg" | head -n 1)
|
| 203 | [ -n "$THUMB" ] && cp "$THUMB" "$SHOW/poster.jpg"
|
| 204 | fi
|
| 205 | done
|
| 206 |
|
| 207 |
|
| 208 |
|
| 209 | echo "===== Removing old JPGs in channel root ====="
|
| 210 |
|
| 211 | # Remove unnecessary JPGs from root (except poster.jpg)
|
| 212 | for CHANNEL in "$YTDLP"/*; do
|
| 213 | [ -d "$CHANNEL" ] || continue
|
| 214 | find "$CHANNEL" -maxdepth 1 -type f -name "*.jpg" ! -name "poster.jpg" -exec rm -f {} +
|
| 215 | done
|
| 216 |
|
| 217 |
|
| 218 |
|
| 219 | echo "===== Converting WEBP thumbnails to JPG with ImageMagick ====="
|
| 220 |
|
| 221 | # Conversion process:
|
| 222 | # - Each *.webp file in the channel folder tree is converted to *.jpg
|
| 223 | # - Original *.webp is deleted later
|
| 224 | # - JPG filename matches video DEST, so Jellyfin can use it automatically
|
| 225 | # Example:
|
| 226 | # /SciManDan/Season 01/S01E01 - Title1 [ID].webp -> S01E01 - Title1 [ID].jpg
|
| 227 |
|
| 228 | # Convert WEBP thumbnails to JPG
|
| 229 | find "$YTDLP" -type f -name "*.webp" | while read -r WEBP; do
|
| 230 | JPG="${WEBP%.webp}.jpg"
|
| 231 | magick "$WEBP" "$JPG" # magick CLI converts webp → jpg
|
| 232 | done
|
| 233 |
|
| 234 |
|
| 235 |
|
| 236 | echo "===== Removing WEBP files ====="
|
| 237 |
|
| 238 | find "$YTDLP" -type f -name "*.webp" -delete # Remove WEBP after conversion
|
| 239 |
|
| 240 |
|
| 241 |
|
| 242 | echo "===== Cleaning up leftover channel metadata ====="
|
| 243 |
|
| 244 | # Remove any leftover .info.json and .description files
|
| 245 | for CHANNEL in "$YTDLP"/*; do
|
| 246 | [ -d "$CHANNEL" ] || continue
|
| 247 | find "$CHANNEL" -maxdepth 1 -type f \( \
|
| 248 | -name "*.info.json" -o \
|
| 249 | -name "*.description" \
|
| 250 | \) -exec rm -f {} +
|
| 251 | done
|
| 252 |
|
| 253 |
|
| 254 |
|
| 255 | echo "===== Cleaning orphaned NFO and JPG files ====="
|
| 256 |
|
| 257 | # Delete any leftover orphaned NFO/JPG if MP4 is missing
|
| 258 | for CHANNEL in "$YTDLP"/*; do
|
| 259 | [ -d "$CHANNEL" ] || continue
|
| 260 | find "$CHANNEL" -type f \( -name "*.info.json" -o -name "*.description" \) -exec rm -f {} +
|
| 261 | done
|
| 262 |
|
| 263 |
|
| 264 |
|
| 265 | echo "===== Change owner rights to Jellyfin ====="
|
| 266 |
|
| 267 | sudo chown -R jellyfin:jellyfin "${YTDLP}" # Final ownership for Jellyfin access
|
| 268 |
|
| 269 | echo "===== ALL DONE ====="
|