move move old scripts
This commit is contained in:
1 parent
93b262977b
commit
208fbd96b4
27 files changed
+346
No files matched your search
Executable
+170
@@ -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()
|
||||
Executable
+211
@@ -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
|
||||
|
||||
Executable
+392
@@ -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()
|
||||
Executable
+1375
File diff suppressed because it is too large.
Load diff
Executable
+183
@@ -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
@@ -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()
|
||||
Executable
+363
@@ -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()
|
||||
Executable
+175
@@ -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
|
||||
Executable
+40
@@ -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."
|
||||
|
||||
Reference in new issue
Block a user