move move old scripts

This commit is contained in:
lucas committed 2026-10-16 14:00:42 +00:00
1 parent 93b262977b
commit 208fbd96b4
27 files changed
+346

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
import re
import sys
from pathlib import Path
from difflib import SequenceMatcher
def normalize(name):
name = Path(name).stem.lower()
# remove brackets
name = re.sub(r"\(.*?\)|\[.*?\]", "", name)
# replace punctuation with spaces
name = re.sub(r"[^a-z0-9]", " ", name)
# collapse whitespace
name = " ".join(name.split())
return name
def similarity(a, b):
words_a = set(normalize(a).split())
words_b = set(normalize(b).split())
if not words_a or not words_b:
return 0.0
# Jaccard similarity
intersection = words_a & words_b
union = words_a | words_b
return len(intersection) / len(union)
def load_playlist(path):
entries = []
with open(path, encoding="utf-8", errors="ignore") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
entries.append(Path(line).name)
return entries
def compare_playlists(orig_file, new_file, threshold=0.85):
orig_files = load_playlist(orig_file)
new_files = load_playlist(new_file)
print(f"\n=== {orig_file.parent} ===")
print(f"Comparing:")
print(f" {orig_file.name}")
print(f" {new_file.name}")
missing = []
for orig in orig_files:
best_match = None
best_score = 0
for candidate in new_files:
score = similarity(orig, candidate)
if score > best_score:
best_score = score
best_match = candidate
if best_score >= threshold:
pass
# print(
# f"MATCH {best_score:.1%} "
# f"{orig} -> {best_match}"
# )
else:
missing.append((orig, best_score, best_match))
if missing:
print("\nMISSING:")
for orig, score, best_match in sorted(
missing,
key=lambda x: x[1],
reverse=True,
):
print(
f" {orig}"
f" (best={score:.1%}"
f"{', candidate=' + best_match if best_match else ''})"
)
return len(missing)
def compare_directory(root, threshold=0.85):
total_missing = 0
playlist_pairs = 0
for orig_file in root.rglob("*_orig.m3u"):
new_file = orig_file.with_name(
orig_file.name.replace("_orig.m3u", ".m3u")
)
if not new_file.exists():
print(
f"\nWARNING: Missing playlist for "
f"{orig_file}"
)
continue
playlist_pairs += 1
total_missing += compare_playlists(
orig_file,
new_file,
threshold,
)
print("\n" + "=" * 80)
print(f"Playlists compared : {playlist_pairs}")
print(f"Total missing : {total_missing}")
def main():
args = sys.argv[1:]
threshold = 0.85
if len(args) == 3 and args[0] == "--threshold":
threshold = float(args[1])
args = [args[2]]
elif len(args) == 4 and args[0] == "--threshold":
threshold = float(args[1])
args = [args[2], args[3]]
if len(args) == 1:
path = Path(args[0])
if not path.is_dir():
print("Single argument must be a directory")
sys.exit(1)
compare_directory(path, threshold)
elif len(args) == 2:
compare_playlists(
Path(args[0]),
Path(args[1]),
threshold,
)
else:
print(
f"Usage:\n"
f" {Path(sys.argv[0]).name} "
f"<playlist_orig.m3u> <playlist.m3u>\n\n"
f" {Path(sys.argv[0]).name} "
f"<directory>\n\n"
f"Optional:\n"
f" --threshold 0.90"
)
sys.exit(1)
if __name__ == "__main__":
main()
+211
View File
@@ -0,0 +1,211 @@
#!/bin/bash
set -euo pipefail
shopt -s nullglob nocaseglob
LOGFILE="/home/lucas/logs/convertheic.log"
mkdir -p "$(dirname "$LOGFILE")"
exec >>"$LOGFILE" 2>&1
echo "Script started at $(date)"
convert_one() {
local src="$1"
if [[ ! -f "$src" ]]; then
echo "ERROR: file not found: $src"
return 1
fi
local base="${src%.*}"
local dst="${base}.jpg"
local tmp="${base}.tmp.jpg"
echo "Converting: $src"
# Convert HEIC -> JPEG
if command -v magick >/dev/null 2>&1; then
if ! magick "$src" \
-auto-orient \
-quality 100 \
-sampling-factor 4:4:4 \
"jpg:$tmp"; then
echo "ERROR: local conversion failed: $src"
rm -f "$tmp"
return 1
fi
else
if ! docker run --rm \
-u "$(id -u):$(id -g)" \
-v "$PWD:/work" \
-w /work \
--entrypoint magick \
dpokidov/imagemagick \
"$src" \
-auto-orient \
-quality 100 \
-sampling-factor 4:4:4 \
"jpg:$tmp"; then
echo "ERROR: docker conversion failed: $src"
rm -f "$tmp"
return 1
fi
fi
if [[ ! -s "$tmp" ]]; then
echo "ERROR: output JPEG is empty: $tmp"
rm -f "$tmp"
return 1
fi
echo "Copying metadata..."
if ! exiftool \
-overwrite_original \
-TagsFromFile "$src" \
-all:all \
-unsafe \
"$tmp" >/dev/null; then
echo "ERROR: metadata copy failed: $src"
rm -f "$tmp"
return 1
fi
touch -r "$src" "$tmp"
mv -f "$tmp" "$dst"
if [[ ! -s "$dst" ]]; then
echo "ERROR: final JPEG missing: $dst"
return 1
fi
rm -f "$src"
echo "Done: $dst"
echo "Removed: $src"
}
convert_mov() {
local src="$1"
if [[ ! -f "$src" ]]; then
echo "ERROR: file not found: $src"
return 1
fi
local base="${src%.*}"
local dst="${base}.mp4"
local tmp="${base}.tmp.mp4"
echo "Converting: $src"
# MOV -> MP4, keep video/audio, drop unsupported data streams
if ! ffmpeg \
-hide_banner \
-loglevel error \
-i "$src" \
-map 0:0 \
-map 0:1 \
-map_metadata 0 \
-c copy \
-movflags use_metadata_tags+faststart \
"$tmp"; then
echo "ERROR: conversion failed: $src"
rm -f "$tmp"
return 1
fi
if [[ ! -s "$tmp" ]]; then
echo "ERROR: output MP4 is empty: $tmp"
rm -f "$tmp"
return 1
fi
echo "Copying metadata..."
if ! exiftool \
-overwrite_original \
-TagsFromFile "$src" \
-all:all \
-unsafe \
"$tmp" >/dev/null; then
echo "ERROR: metadata copy failed: $src"
rm -f "$tmp"
return 1
fi
touch -r "$src" "$tmp"
mv -f "$tmp" "$dst"
if [[ ! -s "$dst" ]]; then
echo "ERROR: final MP4 missing: $dst"
return 1
fi
rm -f "$src"
echo "Done: $dst"
echo "Removed: $src"
}
process_file() {
case "${1,,}" in
*.heic)
convert_one "$1"
;;
*.mov)
convert_mov "$1"
;;
*)
echo "Skipping unsupported file: $1"
;;
esac
}
HOSTNAME_EXPECTED="mediahost"
SOURCE_DIR="/data/nextcloud/syncuser/pictures/Alina Share/Dropfolder"
TARGET_DIR="/data/nextcloud/syncuser/pictures/Camera"
# If running on serverhost, perform initial sync
if [[ "$(hostname)" == "$HOSTNAME_EXPECTED" ]]; then
echo Sync Nextcloud
syncNC -c
cd "$SOURCE_DIR" || exit 1
fi
if [[ $# -gt 0 ]]; then
echo Process single file $1
process_file "$1"
else
echo Process dir .
shopt -s nullglob
files=( *.heic *.mov )
if [[ ${#files[@]} -eq 0 ]]; then
echo "No HEIC or MOV files found."
else
for f in "${files[@]}"; do
process_file "$f"
done
fi
fi
# Run sync again if on serverhost
if [[ "$(hostname)" == "$HOSTNAME_EXPECTED" ]]; then
shopt -s nullglob
for ext in jpg mp4; do
files=("$SOURCE_DIR"/*."$ext")
if ((${#files[@]})); then
mv -n "${files[@]}" "$TARGET_DIR"/
fi
done
shopt -u nullglob
echo Sync Nextcloud
syncNC -c
fi
+392
View File
@@ -0,0 +1,392 @@
#!/usr/bin/env python3
import argparse
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
def check_command(command):
if shutil.which(command) is None:
print(f"ERROR: '{command}' is not installed.", file=sys.stderr)
sys.exit(1)
def scan_dvd(dvd_name: Path):
print("Scanning DVD")
print(dvd_name)
result = subprocess.run(
["lsdvd", dvd_name],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
check=True,
)
output = result.stdout
pattern = re.compile(
r"Title:\s*(\d+),\s*Length:\s*(\d+):(\d+):(\d+(?:\.\d+)?)"
)
titles = []
for match in pattern.finditer(output):
title = int(match.group(1))
hours = int(match.group(2))
minutes = int(match.group(3))
seconds = float(match.group(4))
duration = (
hours * 3600
+ minutes * 60
+ seconds
)
titles.append({
"title": title,
"duration": duration,
})
if not titles:
raise RuntimeError("lsdvd found no DVD titles")
return titles
def get_audio_tracks(dvd, title):
"""Return HandBrake audio track information for a DVD title."""
result = subprocess.run(
[
"HandBrakeCLI",
"-i", dvd,
"-t", str(title),
"--scan",
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
check=True,
)
output = result.stdout
# Examples HandBrake may produce:
#
# + 1, Deutsch (AC3, 2.0 ch, 192 kbps)
# + 2, English (AC3, 2.0 ch, 224 kbps)
#
# Also handles German/Englisch/deu/eng where present.
tracks = []
pattern = re.compile(
r"\+\s+(\d+),\s+(.+?)\s+\((?:AC3|AAC|MP3|E-AC-3|DTS)"
)
for match in pattern.finditer(output):
track_number = int(match.group(1))
description = match.group(2).strip()
tracks.append({
"number": track_number,
"description": description,
})
if not tracks:
tracks.append({
"number": 0,
"description": "",
})
return tracks
def choose_audio_tracks(tracks):
"""
Select German first, English second.
Returns a list of HandBrake track numbers.
"""
german = None
english = None
unkown = None
for track in tracks:
text = track["description"].lower()
if not text:
track["description"] = "Unknown"
unkown = track["number"]
# German
if any(x in text for x in (
"german",
"deutsch",
"deu",
)):
german = track["number"]
# English
if any(x in text for x in (
"english",
"englisch",
"eng",
)):
english = track["number"]
selected = []
# German FIRST -> becomes default audio
if german is not None:
selected.append(german)
# English SECOND
if english is not None and english != german:
selected.append(english)
if english is None and german is None:
selected.append(unkown)
return selected
def find_episode_count(dvd_name: Path, max_dvd_length: int=70):
"""Find the number of episodes using lsdvd.
Assumes:
- The longest title is a combined track containing all episodes.
- Individual episode titles immediately follow the longest title.
- Episode titles have approximately similar durations.
"""
titles = scan_dvd(dvd_name)
sorted_items = sorted(titles, key=lambda x: x["duration"], reverse=True)
combined_title = 0
if sorted_items[0]["duration"] > sorted_items[1]["duration"] * 2:
# Longest title = combined episode track
combined = max(titles, key=lambda x: x["duration"])
combined_title = combined["title"]
# Episode titles must immediately follow the combined title.
title_map = {
t["title"]: t["duration"]
for t in titles
}
first_episode = combined_title + 1
episode_titles = []
for title in range(first_episode, max(title_map) + 1):
if title not in title_map:
break
duration = title_map[title]
# Typical episode is ~20-35 minutes.
# Adjust these limits if your DVDs contain longer/shorter episodes.
if 15 * 60 <= duration <= max_dvd_length * 60:
episode_titles.append(title)
else:
continue
if not episode_titles:
raise RuntimeError(
f"Could not find episodes after combined title "
f"{first_episode}"
)
first_episode = episode_titles[0]
if combined_title > 0:
print(f"Combined title: {titles[combined_title]["title"]}")
print(f"Combined duration: {titles[combined_title]["duration"]:.1f}s")
print(f"Found {len(episode_titles)} episodes:")
print(f" Titles: {episode_titles}")
return first_episode, len(episode_titles), episode_titles
def process_dvd(dvd_source: Path,dvd_title: str,num_of_episodes: int, first_episode: int, args):
output_dir = Path("~/Downloads").expanduser() / dvd_source.parent.name
output_dir.mkdir(parents=True, exist_ok=True)
titles = scan_dvd(dvd_source)
last_episode = first_episode + num_of_episodes
available_titles = {t["title"] for t in titles}
missing = [
title
for title in range(first_episode, last_episode )
if title not in available_titles
]
if missing:
print(
"ERROR: Expected episode titles were not found:",
", ".join(map(str, missing)),
file=sys.stderr,
)
sys.exit(1)
print("Episodes to extract:")
for number in range(1, num_of_episodes + 1):
title = first_episode + number - 1
print(f" Episode {number}: DVD title {title}")
print()
os.makedirs(output_dir, exist_ok=True)
print("Output directory:")
print(f" {output_dir}")
print()
for episode in range(first_episode, first_episode + num_of_episodes ):
title = episode
title_from_zero = episode - first_episode +1
if title_from_zero in args.skip_episodes:
continue
output_file = output_dir / f"{dvd_title} - {title_from_zero:02d}.mp4"
if output_file.exists():
size_mb = output_file.stat().st_size / (1024 * 1024)
print(f"{output_file} ({size_mb:.1f} MB)")
audio_tracks = choose_audio_tracks(get_audio_tracks(dvd_source, title))
if not audio_tracks:
raise RuntimeError(
f"No German or English audio track found for title {title}"
)
print("=" * 50)
print(f"Episode {episode-first_episode +1} / {num_of_episodes}")
print(f"DVD title: {title}")
print(f"Output: {output_file}")
print("=" * 50)
command = [
"HandBrakeCLI",
"-i", dvd_source,
"-t", str(title),
"-o", output_file,
"-e", "x264",
"-q", "18",
"-a", ",".join(map(str, audio_tracks)),
"-E", "copy:ac3",
]
with open("handbrake.log", "w") as logfile:
try:
subprocess.run(
command,
stdout=logfile,
stderr=subprocess.STDOUT,
text=True,
check=True,
)
except subprocess.CalledProcessError as exc:
print(
f"ERROR: HandBrakeCLI failed for episode {episode}.",
file=sys.stderr,
)
sys.exit(exc.returncode)
print()
print("=" * 50)
print("Finished!")
print("=" * 50)
def main():
parser = argparse.ArgumentParser(
description="Extract episodes from a DVD using lsdvd and HandBrakeCLI."
)
parser.add_argument(
"dvd",
help="Path to DVD directory containing VIDEO_TS",
)
parser.add_argument(
"--episodes",
type=int,
default=0,
help="Number of episodes to extract (0 = auto-detect)",
)
parser.add_argument(
"--skip-episodes",
type=int,nargs="+",
default=[],
help="List of episodes to skip",
)
args = parser.parse_args()
dvd_name = os.path.abspath(args.dvd)
check_command("lsdvd")
check_command("HandBrakeCLI")
root = Path(dvd_name).resolve()
check_command("lsdvd")
check_command("HandBrakeCLI")
# Find all VIDEO_TS directories
video_ts_dirs = [
p for p in root.rglob("VIDEO_TS")
if p.is_dir()
]
# Find all ISO files
iso_files = [
p for p in root.rglob("*")
if p.is_file() and p.suffix.lower() == ".iso"
]
# Process VIDEO_TS directories
for video_ts in video_ts_dirs:
dvd_source = video_ts
# Parent of VIDEO_TS, e.g.
# /movies/Pippi DVD 1/VIDEO_TS
# -> Pippi DVD 1
dvd_title = video_ts.parent.name
print(f"Found DVD: {dvd_title}")
print(f" Source: {dvd_source}")
first_episode, episode_count, episode_titles = find_episode_count(dvd_source)
process_dvd(
dvd_source,
dvd_title,
episode_count,
first_episode,
args
)
# Process ISO files
for iso in iso_files:
dvd_source = str(iso)
# Filename without .iso
dvd_title = iso.stem
print(f"Found ISO: {dvd_title}")
print(f" Source: {dvd_source}")
first_episode, episode_count, episode_titles = find_episode_count(dvd_source)
process_dvd(
dvd_source,
dvd_title,
episode_count,
first_episode,
args
)
if __name__ == "__main__":
main()
+1375
View File
File diff suppressed because it is too large. Load diff
+183
View File
@@ -0,0 +1,183 @@
#!/bin/bash
set -e
usage(){
printf "Usage:
-i <file>\tInfo
-av <file>\tChange Audio and Video Codec
-v <file>\tChange Video Codec
-a <file>\tChange Audio Codec
-s <file>\tRemove Subtitles
-rm <file>\tRemove First Audio
-subs <file>\tEmbed subtitles
-dir_down\tDownsize all files in dir to 1920x1080
-find\tFind files from file and resize
-d <file>\tDownsize input video
-filter <file> filter video file\n"
}
if [ $# -lt 2 ]; then
echo "Too few arguments <file> <part of filename to remove>"
else
if [ -d "$2" ] || [ ]; then
echo "Argument is directory"
input_dir=$2
elif [ -f "$2" ]; then
echo "Argument is file"
input=$2
else
echo "Argument $2 doesnt exist"
exit
fi
if [ "$1" == "-av" ]; then
ffmpeg -i "$input" -vcodec mpeg4 -acodec mp3 -preset veryslow -qp 0 "_$input"
elif [ "$1" == "-s" ]; then
ffmpeg -i "$input" -sn -c copy "_$input"
elif [ "$1" == "-a" ]; then
ffmpeg -i "$input" -vcodec copy -acodec mp3 -preset veryslow -qp 0 "_$input"
elif [ "$1" == "-v" ]; then
ffmpeg -i "$input" -vcodec mpeg4 -preset veryslow -qp 0 -acodec copy "_$input"
elif [ "$1" == "-rm" ]; then
ffmpeg -i "$input" -map 0 -map -0:a:0 -c copy -y "_$input";
# mv "_$file" "$file";
echo $input
elif [ "$1" == "-i" ]; then
ffmpeg -i "$input"
elif [ "$1" == "-d" ]; then
ffmpeg -i "$input" -c:v libx264 -crf 27 "${input%%.*}_downsize.${input##*.}"
elif [ "$1" == "-subs" ]; then
#for file in *${input}; do ls "subs/$(echo $file | rev | cut -f2- -d'.' | rev).srt" ; done
for file in *${input}; do
ls "$(echo $file | rev | cut -f2- -d'.' | rev).srt"
ffmpeg -i "$file" -f srt -i "$(echo $file | rev | cut -f2- -d'.' | rev).srt" -c copy -c:s mov_text "_$file"
mv "_$file" "$file"
done
elif [ "$1" == "-frame" ]; then
ffmpeg -ss 00:00:05.01 -i myvideo.avi -frames:v 1 myimage.jpg
elif [ "$1" == "-cut" ]; then
ffmpeg -ss 00:00:02 -i GMT20210112-071517_Vorlesung-_1920x1220.mp4 -to 01:28:49 -c copy output.mp4
elif [ "$1" == "-dir-down" ]; then
echo "Resize all files in ${input_dir}"
if [ ! -d "${input_dir}/comp" ];then
mkdir "${input_dir}/comp"
fi
for file in ${input_dir}/*; do
if [ -f "${file}" ]; then
#echo ${file%.*} ${file##*.} ${file##*/}
ffmpeg -i "${file}" -vf scale=1920x1080 "${input_dir}/comp/${file##*/}" 2>/dev/null 1>&2
fi
done
elif [ "$1" == "-find" ]; then
echo "Find files in ${input_dir}"
if [ ! -d "${input_dir}/comp" ];then
mkdir ${input_dir}/comp
fi
while read -r line; do
file=$(find . -name "$line")
if [ -f "${file}" ]; then
#echo ${file%.*}
#echo ${file%/*}
#echo ${file##*.}
ffmpeg -i "${file}" -vf scale=1920x1080 "${input_dir}/comp/${file##*/}"
fi
done<$3
echo "Converted all files in ${input_dir}"
elif [ "$1" == "-file2mp3" ]; then
codec=$3
find "${input_dir}" -name "*$codec" -print0 | while read -d $'\0' file; do
#echo $file
echo ${file%.*}
#ffmpeg -i "$file" -sample_fmt s16 -ar 48000 "${file%.*}_.flac"< /dev/null
#mv "${file%.*}_.flac" "$file"
ffmpeg -i "$file" -vn -ar 44100 -ac 2 -b:a 192k "${file%.*}.mp3"< /dev/null
#ffmpeg -i "$file" -ab 320k -map_metadata 0 -id3v2_version 3 "${file%.*}.mp3"< /dev/null
done
elif [ "$1" == "-filter" ]; then
#!/usr/bin/env bash
file="$2"
selected_stream="$3"
file_new="${file%.*}_processed.${file##*.}"
if [[ -z "$file" ]]; then
echo "Usage: $0 input_file [audio_stream_index]"
exit 1
fi
# Get list of audio streams
mapfile -t streams < <(
ffprobe -v error -select_streams a \
-show_entries stream=index:stream_tags=language \
-of json "$file" |
jq -r '.streams[] | "\(.index),\(.tags.language // "unknown")"'
)
stream_count=${#streams[@]}
if [[ "$stream_count" -eq 0 ]]; then
echo "No audio streams found."
exit 1
fi
echo "Found $stream_count audio stream(s)."
# If not provided, prompt when multiple exist
if [[ -z "$selected_stream" ]]; then
if [[ "$stream_count" -eq 1 ]]; then
selected_stream="${streams[0]%,*}"
echo "Using only audio stream: $selected_stream"
else
echo "Available audio streams:"
i=0
for s in "${streams[@]}"; do
idx="${s%,*}"
lang="${s#*,}"
echo "[$i] stream_index=$idx language=${lang:-unknown}"
((i++))
done
read -rp "Select audio stream index (ffmpeg stream index): " selected_stream
fi
fi
echo "Processing audio stream: $selected_stream"
ffmpeg -i "$file" \
-map 0:v -c:v copy \
-map 0:a:"$selected_stream" \
-filter_complex "[0:a]highpass=f=100,\
acompressor=threshold=-22dB:ratio=4:attack=10:release=150,\
equalizer=f=2500:t=q:w=1:g=5,\
equalizer=f=4000:t=q:w=1:g=4,\
deesser=i=0.4:f=0.25,\
alimiter=limit=0.95,\
dynaudnorm=f=200:g=15[a]" \
-map "[a]" -c:a libvorbis \
"$file_new"
echo "Done: $file_new"
else
usage
exit
fi
fi
#Uncompatible Videocodecs:
# mpeg4 (DX50 / 0x30355844)
#h264 (High) (avc1 / 0x31637661)
#Uncompatible AUdiocodecs:
#Multiple Audiolines
#Subtitles
# ffmpeg -i $input -sn -vcodec copy -acodec copy
#Keep Audio/Video Settings
# ffmpeg -i $input -acodec copy -vcodec copy
#delete audio trach 0:1
#for file in *avi ; do ffmpeg -i "$file" -map 0:0 -map 0:2 -acodec copy -vcodec copy "New_$file"; mv "New_$file" "$file"; done
#ffmpeg -ss 00:00:00 -i Stargate.mp4 -to 02:09:39 -c copy output.mp4
# Embed Subtitles
#for file in Subs/.*srt; do ffmpeg -i "$file" "$(echo $file | sed 's/srt/ass/g')"; done
#for file in *avi; do ffmpeg -i "$file" -vf ass="Subs/$(echo $file | sed 's/avi/ass/g' | sed 's/S01E/s01e/g')" "New1_$file" ;done
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env python3
import os
import re
import logging
import argparse
from pathlib import Path
from difflib import SequenceMatcher
WORKDIR = Path("/data/navidrome/music/")
PLAYLIST_DIR = WORKDIR / "playlists"
MUSIC_DIR = WORKDIR / "music"
AUDIO_EXTS = {".mp3", ".flac", ".m4a", ".ogg", ".opus", ".aac", ".wav", ".ape"}
TRACE = 5
score_threshold = 0.6
folder_score_threshold = 0.6
logging.addLevelName(TRACE, "TRACE")
weight_jaccard = 0.6
weight_fuzzy = 1 - weight_jaccard
class CustomLogger(logging.Logger):
def trace(self, msg: str, *args, **kwargs) -> None:
if self.isEnabledFor(TRACE):
self._log(TRACE, msg, args, **kwargs)
logging.setLoggerClass(CustomLogger)
logger: CustomLogger = logging.getLogger(__name__) # type: ignore[assignment]
def words(text):
return set(filter(None, re.split(r"[^a-z0-9]+", text.lower())))
def word_similarity(a: str, b: str) -> float:
return SequenceMatcher(None, a, b).ratio()
def score_match_jaccard(search_words, candidate_words):
intersection = search_words & candidate_words
union = search_words | candidate_words
if not union:
return 0.0
return len(intersection) / len(union)
def score_match_fuzzy(search_words, candidate_words):
total = 0.0
for sw in search_words:
best = 0.0
for cw in candidate_words:
best = max(best, word_similarity(sw, cw))
total += best
# logger.trace(f"Total: {total}/{len(search_words)}")
return total / len(search_words)
def combined_score(score_fuzzy: float, score_jaccard: float) -> float:
return score_fuzzy * weight_fuzzy + score_jaccard * weight_jaccard
def find_folder(directory: Path, folder: str) -> list[Path]:
matches: list[tuple[Path, float]] = []
search_words = words(folder)
for candidate in directory.iterdir():
if not candidate.is_dir():
continue
candidate_words = words(candidate.name)
score_fuzzy = score_match_fuzzy(search_words, candidate_words)
score_jaccard = score_match_jaccard(search_words, candidate_words)
score = combined_score(score_fuzzy, score_jaccard)
logger.trace(f"Candidate: {score:.2f}:{candidate_words}")
if score > folder_score_threshold:
matches.append((candidate, score))
ret = [
path
for path, _ in sorted(
matches,
key=lambda x: x[1],
reverse=True,
)
]
logger.debug(f"Find Folders Return: {ret}")
return ret
def find_replacement(old_path: Path):
p_old_path = Path(old_path)
playlist_filename = p_old_path.stem
# Find the second "music" directory
music_idx = p_old_path.parts.index("music", p_old_path.parts.index("music") + 1)
folder_count = len(p_old_path.parts) - music_idx - 2
logger.debug(f"Folder Count Index: {folder_count}")
try:
artist = p_old_path.parts[len(WORKDIR.parts) + 1] # /music/Artist/Album/File
logger.debug(f"Artist: {artist}")
except IndexError:
logger.error(f"No Artist found for {p_old_path}")
return None
if artist == "Musik":
artist_directories = [MUSIC_DIR / Path(playlist_filename.split(" - ")[0])]
else:
artist_directories = find_folder(MUSIC_DIR, artist)
if not artist_directories:
logger.error(f"Artist dir {artist_directories} dors not exist")
return None
logger.debug(f"Artist paths: {artist_directories}")
album_directories = []
subalbum_directories = []
search_dir_list = artist_directories
if folder_count >= 2:
for artist_dir in artist_directories:
try:
album = p_old_path.parts[
len(WORKDIR.parts) + 2
] # .../Artist/Album/File
logger.debug(f"Album: {album}")
except IndexError:
logger.error(f"No album found for {p_old_path}")
return None
album_directories.extend(find_folder(artist_dir, album))
if not album_directories:
logger.warning(f"Album dir for '{album}' does not exist")
if album_directories:
logger.debug(f"Album path: {album_directories}")
search_dir_list = album_directories
if folder_count >= 3:
for album_dir, score_fuzzy in album_directories:
try:
subalbum = p_old_path.parts[
len(WORKDIR.parts) + 3
] # .../Artist/Album/File
logger.debug(f"Sub-Album: {subalbum}")
except IndexError:
logger.error(f"No Sub-Album found for {p_old_path}")
return None
subalbum_directories.extend(find_folder(album_dir, subalbum))
if not subalbum_directories:
logger.error(f"Sub-Album dir for '{subalbum}' does not exist")
return None
if subalbum_directories:
logger.debug(f"Sub-Album path: {subalbum_directories}")
search_dir_list = subalbum_directories
if not search_dir_list:
logger.error(f"Search list empty")
return None
if (
len(artist_directories) == 1
and artist_directories[0].name.lower() in playlist_filename.lower()
):
logger.debug(f"Replace {artist_directories[0].name} in {playlist_filename}")
playlist_filename = re.sub(
artist_directories[0].name, "", playlist_filename, flags=re.IGNORECASE
)
logger.debug(f"Filename minus Artist: {playlist_filename}")
original = playlist_filename
playlist_filename = re.sub(
r"\s*[\(\[].*?[\)\]]",
"",
playlist_filename,
)
playlist_filename = re.sub(r"\s+", " ", playlist_filename).strip()
if playlist_filename != original:
logger.debug(f"Removed bracketed text: '{original}' -> '{playlist_filename}'")
search_words = words(playlist_filename)
best_match = None
best_score = 0
for search_dir in search_dir_list:
for root, _, files in os.walk(search_dir):
logger.debug(f"Search words {search_words}")
logger.debug(f"Walk Directory: {root}")
for filename in files:
candidate = Path(root) / filename
candidate_parts = candidate.stem.split(" - ")
logger.trace(f"Candidate: {candidate}")
if candidate.suffix.lower() not in AUDIO_EXTS:
continue
separator_count = len(candidate.parent.name.split(" - ")) - 1
if len(candidate_parts) == 4 + separator_count:
candidate_words = words(
" ".join(candidate_parts[2 + separator_count :])
)
filename_structure = "LidArr"
else:
filename_structure = "Fallback"
candidate_words = words(candidate.stem)
score_fuzzy = score_match_fuzzy(search_words, candidate_words)
score_jaccard = score_match_jaccard(search_words, candidate_words)
score = combined_score(score_fuzzy, score_jaccard)
logger.trace(
f"{filename_structure} Words: {candidate_words} {score:.2f} / {best_score:.2f}, "
f"{weight_fuzzy:.2} * {score_fuzzy:.2f} + {weight_jaccard:.2} * {score_jaccard:.2f} "
)
if score > best_score:
best_score = score
best_match = candidate
# Require all words to match
if best_match and best_score > score_threshold:
logger.debug(f"Best Match: {best_match}")
return best_match
logger.error(
f"No match found with score greater {score_threshold:.2f}, best match: {best_score:.2f}:{best_match}"
)
return None
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"path",
nargs="?",
default=PLAYLIST_DIR,
help="Playlist file or directory",
)
parser.add_argument(
"-ll",
"--log-level",
type=str.upper,
default="INFO",
choices=["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Set log level",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Dry run only",
)
args = parser.parse_args()
LOG_LEVELS = {
"TRACE": TRACE,
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARNING": logging.WARNING,
"ERROR": logging.ERROR,
"CRITICAL": logging.CRITICAL,
}
logging.basicConfig(
level=LOG_LEVELS[args.log_level.upper()],
format="%(asctime)s %(levelname)s %(message)s",
)
playlist_path = Path(args.path)
if playlist_path.is_file():
playlists = [playlist_path.absolute()]
logger.info(f"Edit single playlist: {playlists}")
elif playlist_path.is_dir():
playlists = [p for p in playlist_path.glob("*.m3u") if "_new.m3u" not in p.name]
logger.info(f"Edit all playlists in {playlist_path.name}: {playlists}")
else:
logger.error(f"{playlist_path} does not exist")
return
for playlist in playlists:
if "_new.m3u" in playlist.name:
continue
logger.info(f"\nProcessing {playlist}")
changed = False
output = []
with open(playlist, "r", encoding="utf-8", errors="ignore") as f:
lines = f.readlines()
for line in lines:
if not line or line.startswith("#"):
# output.append(line)
# Ignore EXINF line as they dont matter
continue
path = WORKDIR / Path(line.strip()).relative_to("/")
if not path.is_absolute() or path.exists():
output.append(line)
continue
logger.debug(f"Missing: {path}")
replacement = find_replacement(path)
if replacement:
logger.info(f"{path.name} -> {replacement.name}")
replacement = Path(
str(replacement).removeprefix("/data/navidrome/music")
)
output.append(str(replacement) + "\n")
changed = True
else:
logger.error(f" -> No match for {path}")
output.append(line + "\n")
if changed and not args.dry_run:
backup_dir = playlist.parent.with_name(f"{playlist.parent.name}-bak")
backup_playlist = backup_dir / playlist.name
if not backup_playlist.exists():
logger.info(f"Generate Backup playlist {backup_playlist}")
backup_playlist.write_text(
playlist.read_text(encoding="utf-8"), encoding="utf-8"
)
with open(
playlist.with_name(playlist.stem + ".m3u"),
"w",
encoding="utf-8",
# playlist.with_name(playlist.stem + "_new.m3u"), "w", encoding="utf-8"
) as f:
f.writelines(output)
logger.info("Updated playlist")
if __name__ == "__main__":
main()
+363
View File
@@ -0,0 +1,363 @@
#!/usr/bin/env python3
import re
import sys
import requests
import argparse
from pathlib import Path
from difflib import SequenceMatcher
TMDB_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJlOTIwMzJlMDk1NmEzMWEyZjQ4MDA4NGI0MTg4OTIyZCIsIm5iZiI6MTc5MDQzMjc5NS42NjcsInN1YiI6IjZhYjdkNjFiZDY5NmI1ZGFjNGM5NDRkMyIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.IjddKtQ5X62A63kYslH24Pxg_TFCzu-QpGak_Pemgr8"
if not TMDB_TOKEN:
print("ERROR: TMDB_API_TOKEN is not set.")
print()
print("Example:")
print(" export TMDB_API_TOKEN='your_token'")
sys.exit(1)
BASE_URL = "https://api.themoviedb.org/3"
HEADERS = {
"Authorization": f"Bearer {TMDB_TOKEN}",
"accept": "application/json",
}
def tmdb_get(endpoint, params=None):
"""Perform a GET request against TMDb."""
url = BASE_URL + endpoint
print(f"Set of Tmdb query: {url}")
response = requests.get(
url,
headers=HEADERS,
params=params,
timeout=15,
)
response.raise_for_status()
return response.json()
def find_by_title(title, media_type="tv", year=None, language="de-DE"):
"""
Search TMDB by title.
media_type:
tv | movie
"""
params = {
"query": title,
"language": language,
}
if year:
if media_type == "tv":
params["first_air_date_year"] = year
else:
params["year"] = year
return tmdb_get(
f"/search/{media_type}",
params,
)
def find_by_tmdb_id(tmdb_id, language="de-DE"):
"""
Try to determine whether a TMDB ID is a TV show or movie.
Returns:
("tv", data)
("movie", data)
(None, None)
"""
try:
data = get_tv_details(tmdb_id, language)
return "tv", data
except requests.HTTPError:
pass
try:
data = get_movie_details(tmdb_id, language)
return "movie", data
except requests.HTTPError:
pass
return None, None
def get_tv_details(tmdb_id , language="de-DE"):
"""
Retrieve TV show details in German.
"""
return tmdb_get(
f"/tv/{tmdb_id}",
{
"language": language,
},
)
def get_movie_details(tmdb_id, language="de-DE"):
"""
Retrieve movie details in German.
"""
return tmdb_get(
f"/movie/{tmdb_id}",
{
"language": language,
},
)
def get_season(tmdb_id, season_number):
return tmdb_get(
f"/tv/{tmdb_id}/season/{season_number}",
{
"language": "de-DE",
},
)
def get_episode_list(show,series_path: Path):
#_, show = find_by_tmdb_id(tmdb_id)
file_seasons = sorted({
int(m.group(1))
for d in series_path.iterdir()
if d.is_dir()
and (m := re.match(r"^Season\s+0*(\d+)$", d.name, re.I))
})
episodes = []
for season in show.get("seasons", []):
season_number = season["season_number"]
# skip specials if desired
if season_number == 0 or season_number not in file_seasons:
continue
season_data = get_season(show.get("id"), season_number)
for episode in season_data.get("episodes", []):
episodes.append({
"season": season_number,
"episode": episode["episode_number"],
"title": episode["name"],
"air_date": episode["air_date"],
"runtime": episode.get("runtime"),
"tmdb_id": episode["id"],
})
return episodes
def print_results(result):
media_type, data = result
print(f"Type : {media_type}")
print(f"TMDB ID : {data.get('id')}")
print(f"Title : {data.get('name') or data.get('title')}")
print(f"Original : {data.get('original_name') or data.get('original_title')}")
print(f"Year : {(data.get('first_air_date') or data.get('release_date') or '')[:4]}")
print(f"Rating : {data.get('vote_average')}")
print(f"Votes : {data.get('vote_count')}")
print(f"Status : {data.get('status')}")
print(f"Tagline : {data.get('tagline')}")
print(f"Overview : {data.get('overview')}")
genres = ", ".join(g["name"] for g in data.get("genres", []))
print(f"Genres : {genres}")
print(f"Poster : https://image.tmdb.org/t/p/original{data.get('poster_path', '')}")
print(f"Backdrop : https://image.tmdb.org/t/p/original{data.get('backdrop_path', '')}")
if media_type == "tv":
print(f"Seasons : {data.get('number_of_seasons')}")
print(f"Episodes : {data.get('number_of_episodes')}")
def normalize(text, title, year):
text = text.lower()
# remove series title
text = re.sub(re.escape(title.lower()), " ", text)
# remove year
text = re.sub(rf"\b{year}\b", " ", text)
# remove anything in [] including the brackets
text = re.sub(r"\[[^\]]*\]", " ", text)
# remove punctuation
text = re.sub(r"[^\w\s]", " ", text)
# remove episode numbers like S01E01
text = re.sub(r"\bs\d+e\d+\b", " ", text, flags=re.I)
# collapse spaces
text = re.sub(r"\s+", " ", text).strip()
return text
def best_episode_match(filename, episodes,title,year):
filename_norm = normalize(Path(filename).stem, title, year)
best = None
best_score = 0
for ep in episodes:
score = SequenceMatcher(
None,
filename_norm,
normalize(ep["title"], title, year)
).ratio()
if score > best_score:
best_score = score
best = ep
return best, best_score
def rename_media(media_data,episodes, series_path,file_title):
series=series_path.name
episode_map = {
(ep["season"], ep["episode"]): ep
for ep in episodes
}
file_list = []
new_file_list = []
for file in series_path.rglob("*"):
match_SE_name = re.search(r"S(\d{2})E(\d{2})", file.name, re.I)
if not match_SE_name:
match_season_folder = re.search(r"Season (\d{2})", file.name, re.I)
if match_season_folder:
continue # witchout bothering anyone
print(f"No season/episode number found: {file.name}")
continue
season = int(match_SE_name.group(1))
episode= int(match_SE_name.group(2))
episode_number = episode_map.get((season, episode))
if not episode_number:
print(f"Episode not found: S{season:02d}E{episode:02d}")
continue
episode_season_number_match = re.match(
r"^(.*?S\d{2}E\d{2})\s*-\s*(.*?)\s*(\[[^\]]+\].*)?$",
file.stem,
re.I,
)
if not episode_season_number_match:
print(f"S{season:02d}E{episode:02d} not found")
continue
prefix = episode_season_number_match.group(1).replace(file_title,(media_data.get('name')
or media_data.get('title'))) # Andor (2022) - S01E07
quality = episode_season_number_match.group(3) or "" # [WEBRip-1080p][AAC 5.1][h265]
new_name = (
f"{prefix} - {episode_number['title']}"
f"{(' ' + quality) if quality else ''}"
f"{file.suffix}"
)
print(f"{file.name} -> {new_name}")
file_list.append(file)
new_file_list.append(new_name)
title=(f"{(media_data.get('name') or media_data.get('title'))} "
f"({(media_data.get('first_air_date') or media_data.get('release_date') or '')[:4]}) "
f"[{media_data.get('id')}]")
print(f"{series} -> {title}")
file_list.append(series_path)
new_file_list.append(title)
has_changes = False
for file, new_file in zip(file_list, new_file_list):
if file.name != new_file:
has_changes = True
break
if not has_changes:
print("No changes necessary.")
return
answer = input("Rename files? [y/N]: ").strip().lower()
if answer in ("y", "yes"):
print("Proceeding...")
for file, new_file in zip(file_list,new_file_list):
file.rename(file.with_name(new_file))
print("All files renamed")
return
print("Cancelled.")
return
def process_series(series_path: Path):
series = series_path.name
m = re.match(r"^(.*?)\s*\((\d{4})\)\s*\[(\d+)\]$", series)
if not m:
print(f"Skipping invalid folder: {series}")
return
file_title = m.group(1).strip()
#file_year = int(m.group(2))
file_tmdb_id = int(m.group(3))
if file_tmdb_id == 0:
print("TMDB ID required")
return
try:
data = find_by_tmdb_id(file_tmdb_id)
if not data:
print("TMDB lookup failed")
return
media_type, media_data = data
#print_results(data)
episodes = get_episode_list(media_data,series_path)
rename_media(
media_data,
episodes,
series_path,
file_title
)
except Exception as e:
print(f"Error processing {series}: {e}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"path",
help='Either a series folder "Maya the Bee (1975) [10620]" or a root folder containing such folders'
)
args = parser.parse_args()
input_path = Path(args.path)
if not input_path.is_dir():
parser.error(f"Directory not found: {input_path}")
# Input is already a series folder
if re.match(r"^(.*?)\s*\((\d{4})\)\s*\[(\d+)\]$", input_path.name):
process_series(input_path)
# Input is a root folder containing series folders
else:
for series_path in sorted(input_path.iterdir()):
if series_path.is_dir():
process_series(series_path)
if __name__ == "__main__":
main()
+175
View File
@@ -0,0 +1,175 @@
#!/bin/bash
LIST=false
RETRIES=""
PL_NAME="Default"
LOG=1
youtubelist="/home/lucas/scripts/youtubelist.txt"
MUSIC=false
VIDEO=true
DATE="$(date +'%m%d%H%M%S')"
log="pl_log_${DATE}.log"
DISABLE_SERVERMODE=false
#pl_options="--playlist-end 11"
pl_options=""
vid_options=""
get_config() {
while [[ ${1:0:1} == - ]]; do
echo "Param: $1"
[[ $1 == -retry ]] && {
echo "Retriy Mode"
RETRIES="--retries 100 --fragment-retries 100"
shift 1;
continue;
};
[[ $1 == -log ]] && {
echo "Playlist Log Mode"
LOG=0
shift 1;
continue;
};
[[ $1 == -pl_name ]] && {
if [ $# -lt 2 ]; then
usage
exit
fi
echo "Playlist Dir Name: $2"
PL_NAME=$2;
shift 2;
continue;
};
[[ $1 == -h ]] && {
usage
shift 1;
exit 0;
};
[[ $1 == -L ]] && {
LIST=true
shift 1;
continue;
};
[[ $1 == -disable ]] && {
DISABLE_SERVERMODE=true
shift 1;
continue;
};
[[ $1 == -M ]] && {
MUSIC=true
VIDEO=false
shift 1;
continue;
};
echo "Parameter $1 discarded"
shift 1;
done
}
write_log() {
echo "$@"
echo "$@" >> "${DL_PATH}${log}"
}
usage(){
printf "Usage:
-pl_name <name>\tGive Playlist name for -log mode
-retry\t\tUse more Retries
-log\t\tDownload Playlist Video Titles to Log File
-h\t\tHelpe
-M Download Music
-disable\tDisable VPN\n"
}
get_config "$@"
if [ ! -d ${DL_PATH} ]; then
mkdir "${DL_PATH}"
fi
if [ ! -f ${youtubelist} ] ; then
echo "No youtubelist present"
fi
if [[ "$(hostname)" == "serverhost" ]] && [[ "${DISABLE_SERVERMODE}" == "false" ]]; then
duser_cmd="sudo bash /home/lucas/scripts/serverhost/deluge_user_cmd.sh"
duser_cmd=""
DL_PATH="/torrent/youtube/"
echo "Serverhost mode - VPN"
elif [[ "$(hostname)" == "serverhost" ]]; then
duser_cmd=""
DL_PATH="/torrent/youtube/"
echo "Serverhost mode - No VPN"
else
duser_cmd=""
DL_PATH="/home/lucas/Downloads/"
fi
echo "IP: $(${duser_cmd} wget -T 5 -qO- ifconfig.co)|$(${duser_cmd} wget -T 5 -qO- ifconfig.co/country) "
count_list=1
vid_num="$(cat ${youtubelist} | wc -l | grep "")"
IFS=$'\n'; set -f
for VID in $(cat ${youtubelist}); do
write_log "$VID"
write_log "********************* VID ${count_list} of ${vid_num} *********************"
if [ "$(echo ${VID} | grep "playlist")" != "" ]; then
write_log "Playlist found"
PL_FAIL=0
if [ ${LOG} -eq 1 ]; then
write_log "Log Mode only, ${VID}"
${duser_cmd} yt-dlp ${RETRIES} -w -i "${VID}" ${pl_options} --output "${DL_PATH}%(uploader)s/%(playlist)s/%(upload_date)s_%(title)s.%(ext)s" --merge-output-format mp4 --remux-video mp4
fi
write_log "Logfile ${log}"
pl_name="$(printf "%s" "${VID}" | rev | cut -f1 -d'=' | rev)"
log_name="${DL_PATH}pl_log_${pl_name}.log"
echo $pl_name
echo $log_name
if [ ! -f ${log_name} ]; then
write_log "New Playlist File ${log_name}"
if ${MUSIC}; then
write_log "Download Music File"
${duser_cmd} yt-dlp -j --flat-playlist "${VID}" ${pl_options} | cut -f4 -d',' | cut -f2- -d':' | tr -d ' "' > ${log_name}
else
${duser_cmd} yt-dlp -j --flat-playlist "${VID}" ${pl_options} | cut -f4 -d',' | cut -f2- -d':' | tr -d ' "' > ${log_name}
fi
else
write_log "${log_name} already present"
fi
while [ $(cat ${log_name} | wc -l) -ne 0 ] ; do
count_playlist=0
count_playlist_fail=0
count_playlist_suc=0
lines=$(cat ${log_name} | wc -l)
write_log "${lines} files to download"
for line in $(cat ${log_name}); do
if ${MUSIC}; then
write_log "Download Music File"
${duser_cmd} yt-dlp ${RETRIES} "${line}" -f bestaudio -x --audio-format mp3 --audio-quality 320k --embed-thumbnail --add-metadata --postprocessor-args "-id3v2_version 3" --output "${DL_PATH}%(uploader)s/%(playlist)s_${pl_name}/%(title)s.%(ext)s"
else
${duser_cmd} yt-dlp --cookies-from-browser chrome ${RETRIES} -w -i "${line}" "${vid_options}" --output "${DL_PATH}%(uploader)s/%(playlist)s_${pl_name}/%(upload_date)s_%(title)s.%(ext)s" --merge-output-format mp4 --remux-video mp4
fi
ret=$?
if [ ${ret} -eq 0 ]; then
((count_playlist_suc++))
write_log "Remove ${line} from file ${log_name}"
cat ${log_name} | tr '\n' ';' | sed 's#'"${line}"';##g' | tr ';' '\n' > buf
mv buf ${log_name}
else
((count_playlist_fail++))
fi
((count_playlist++))
write_log "${count_playlist}/${lines} : ${line} : ${ret} - Sucess: ${count_playlist_suc} : Fail: ${count_playlist_fail}"
done
if [[ ${PL_FAIL} -gt 5 ]];then
break
fi
((PL_FAIL++))
echo "Increase PL_FAIL to ${PL_FAIL}"
done
else
if [ "$(echo ${VID} | grep youtube)" != "" ]; then
write_log "YouTube Video found"
${duser_cmd} yt-dlp ${RETRIES} -w -i "${VID}" ${pl_options} --output "${DL_PATH}%(uploader)s/%(playlist)s/%(upload_date)s_%(title)s.%(ext)s" --merge-output-format mkv
else
write_log "General Video found"
$(${duser_cmd}) yt-dlp -w -i "${VID}" --output "${DL_PATH}%(title)s_${count_list}.%(ext)s" --force-ipv4 --merge-output-format mp4 --remux-video mp4
echo "${duser_cmd} yt-dlp ${RETRIES} -w -i ${VID} --output ${DL_PATH}%(title)s_${count_list}.%(ext)s --force-ipv4 --merge-output-format mp4 --remux-video mp4"
fi
fi
((count_list++))
done
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Usage:
# ./download_mp3s.sh links.txt [output_dir]
#
# links.txt: text file containing one URL per line
# output_dir: optional destination directory (default: ./downloads)
LINKS_FILE="${1:-links.txt}"
OUTDIR="${2:-downloads}"
if ! command -v yt-dlp >/dev/null 2>&1; then
echo "Error: yt-dlp not found."
exit 1
fi
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "Error: ffmpeg not found."
exit 1
fi
mkdir -p "$OUTDIR"
yt-dlp \
--newline \
--ignore-errors \
--extract-audio \
--audio-format mp3 \
--audio-quality 0 \
--embed-metadata \
--embed-thumbnail \
--add-metadata \
--parse-metadata "%(artist|)s:%(meta_artist)s" \
--convert-thumbnails jpg \
--output "$OUTDIR/%(uploader)s - %(title)s.%(ext)s" \
-a "$LINKS_FILE"
echo "Done."