Compare commits
124
Commits
881bdcd925
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
815cd5a551 | ||
|
|
bcc927db73 | ||
|
|
2d97877d00 | ||
|
|
73b870b142 | ||
|
|
ad796c174e | ||
|
|
18e1fc9325 | ||
|
|
2d623bbff6 | ||
|
|
9929dcbd8c | ||
|
|
07229a4435 | ||
|
|
22522549b5 | ||
|
|
4b4b0fcb5f | ||
|
|
ec44b53a53 | ||
|
|
2a48f50717 | ||
|
|
cabf1a21a8 | ||
|
|
298519a446 | ||
|
|
c4a717a8a8 | ||
|
|
7f2048cda6 | ||
|
|
3f19182d35 | ||
|
|
b9735e9381 | ||
|
|
25432d1650 | ||
|
|
2af4724093 | ||
|
|
64f2abcdd1 | ||
|
|
a81ab49b91 | ||
|
|
51b6b28c91 | ||
|
|
b672696f91 | ||
|
|
d43fcd530c | ||
|
|
6d229c59f7 | ||
|
|
2d544d317f | ||
|
|
6e9cc10b46 | ||
|
|
2ea486a8c0 | ||
|
|
04a8253755 | ||
|
|
b3e9bae3eb | ||
|
|
7fbda6b133 | ||
|
|
c52c83f7ff | ||
|
|
b875c1e193 | ||
|
|
7e7a8c3af5 | ||
|
|
2ec34dd1be | ||
|
|
5ec0108117 | ||
|
|
845446e1fe | ||
|
|
29de1e0f80 | ||
|
|
a553e51dd7 | ||
|
|
cfdadfd9aa | ||
|
|
a6bd1c2d1a | ||
|
|
07e24ad97e | ||
|
|
bbc56a9908 | ||
|
|
7214cfd52b | ||
|
|
58795e9d5e | ||
|
|
d241017c2a | ||
|
|
104b912404 | ||
|
|
e4ebc3c5b2 | ||
|
|
6e369c3656 | ||
|
|
63c34f5387 | ||
|
|
fcde06e8b6 | ||
|
|
f54919554b | ||
|
|
93a464f49d | ||
|
|
437c91dc7a | ||
|
|
7f27f187d8 | ||
|
|
073ddf11fc | ||
|
|
f5d7962aba | ||
|
|
0a6457abe0 | ||
|
|
aa27e1f350 | ||
|
|
59f1bcb5c1 | ||
|
|
b1c8365bd8 | ||
|
|
c5cf55fe21 | ||
|
|
99573fb241 | ||
|
|
f9d97de61f | ||
|
|
d5c82e364d | ||
|
|
c192114bcf | ||
|
|
179256de3e | ||
|
|
0e52aefa07 | ||
|
|
95c2f90031 | ||
|
|
1dd80d9a45 | ||
|
|
dc537635bb | ||
|
|
be012e334b | ||
|
|
35c2104852 | ||
|
|
d1e2f8a3e5 | ||
|
|
2eb7b8d6d1 | ||
|
|
045861ada6 | ||
|
|
8ae894ffb6 | ||
|
|
208fbd96b4 | ||
|
|
93b262977b | ||
|
|
fd91e7d678 | ||
|
|
9a476ef5cd | ||
|
|
dbb8eda1e6 | ||
|
|
2e273985b7 | ||
|
|
f7865f05a1 | ||
|
|
719c7068e2 | ||
|
|
580b667119 | ||
|
|
3bf1fc1be6 | ||
|
|
7b0b438740 | ||
|
|
2eda7cd33e | ||
|
|
efc150d9ce | ||
|
|
044fbd81ad | ||
|
|
01ab319eea | ||
|
|
d5e627325d | ||
|
|
deaff35bc3 | ||
|
|
27d570fc5c | ||
|
|
d5fb01adb2 | ||
|
|
52c02beb3d | ||
|
|
3f14f53184 | ||
|
|
c41bf36afb | ||
|
|
07bab58ae0 | ||
|
|
d06cff4cbd | ||
|
|
09343a7523 | ||
|
|
dc0cd02e58 | ||
|
|
7158dd4960 | ||
|
|
4c299c5a38 | ||
|
|
2f3a763054 | ||
|
|
f7ff789d81 | ||
|
|
7c6a121504 | ||
|
|
144d974731 | ||
|
|
9e4901699d | ||
|
|
8c29731ddc | ||
|
|
80ad918686 | ||
|
|
3d585d14e0 | ||
|
|
8b6fc7fdcd | ||
|
|
e4f1cce494 | ||
|
|
8cccf674cf | ||
|
|
86679e4e18 | ||
|
|
fc9a2dcf0e | ||
|
|
57520eaa04 | ||
|
|
f9ee0a0694 | ||
|
|
1af47022e9 | ||
|
|
ce656b6970 |
No files matched your search
@@ -15,8 +15,6 @@ wheels/
|
||||
.idea
|
||||
uv.lock
|
||||
youtubelist.txt
|
||||
*.mkv
|
||||
*.mp4
|
||||
.spotdl-cache
|
||||
script_template.txt
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".")
|
||||
|
||||
audio_extensions = {
|
||||
".m4b",
|
||||
".m4a",
|
||||
".mp3",
|
||||
".flac",
|
||||
".ogg",
|
||||
}
|
||||
|
||||
|
||||
def get_metadata(field, file):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["kid3-cli", "-c", f"get {field}", str(file)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
|
||||
|
||||
# Include ROOT itself, matching find "$ROOT" -type d
|
||||
for directory in [ROOT] + [p for p in ROOT.rglob("*") if p.is_dir()]:
|
||||
audio_files = sorted(
|
||||
(
|
||||
file
|
||||
for file in directory.iterdir()
|
||||
if file.is_file()
|
||||
and file.suffix.lower() in audio_extensions
|
||||
),
|
||||
key=lambda p: str(p),
|
||||
)
|
||||
|
||||
if not audio_files:
|
||||
continue
|
||||
|
||||
first_file = audio_files[0]
|
||||
|
||||
artist = get_metadata("artist", first_file)
|
||||
album = get_metadata("album", first_file)
|
||||
composer = get_metadata("composer", first_file)
|
||||
comment = get_metadata("comment", first_file)
|
||||
|
||||
print("====≠===============================")
|
||||
print(f"Folder: {directory.name}")
|
||||
print(f"Album: {album}")
|
||||
print(f"Artist: {artist}")
|
||||
print(f"Narrator: {composer}")
|
||||
print(f"Comment: {comment}")
|
||||
+1107
File diff suppressed because it is too large.
Load diff
File renamed without changes.
+172
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/python3
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
def load_genre(conn, unique=False):
|
||||
"""
|
||||
Return all genres from the books table.
|
||||
|
||||
Args:
|
||||
conn: sqlite3 connection
|
||||
unique: if True, return unique genres only
|
||||
|
||||
Returns:
|
||||
list[str]
|
||||
"""
|
||||
genres = []
|
||||
|
||||
for (genres_json,) in conn.execute("SELECT genres FROM books"):
|
||||
if not genres_json:
|
||||
continue
|
||||
|
||||
try:
|
||||
genres.extend(json.loads(genres_json))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if unique:
|
||||
return sorted(set(genres))
|
||||
|
||||
return genres
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"-p", "--print-only",
|
||||
action="store_true",
|
||||
help="Print data only",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--print-all",
|
||||
action="store_true",
|
||||
help="Print all data only",
|
||||
)
|
||||
parser.add_argument(
|
||||
"database",
|
||||
type=Path,
|
||||
help="SQL Database Path",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
database = args.database.resolve()
|
||||
|
||||
if not database.is_file():
|
||||
parser.error(f"Database is not a file: {database}")
|
||||
|
||||
db = sqlite3.connect(database)
|
||||
cur = db.cursor()
|
||||
|
||||
list_genres = load_genre(cur,True)
|
||||
if args.print_all:
|
||||
for genre in list_genres:
|
||||
print(genre)
|
||||
return
|
||||
|
||||
rows = cur.execute(
|
||||
"SELECT id, genres FROM books"
|
||||
).fetchall()
|
||||
|
||||
for book_id, genres_json in rows:
|
||||
if not genres_json:
|
||||
continue
|
||||
genres = json.loads(genres_json)
|
||||
genres_new = []
|
||||
new_genres = []
|
||||
print("=================================")
|
||||
print(f"Before sed: {genres}")
|
||||
for genre in genres:
|
||||
if "Drachen" in genre:
|
||||
pass
|
||||
if "Fantastic" in genre:
|
||||
pass
|
||||
genre = re.sub(r'(?i)^audio\s*books?$', '', genre)
|
||||
genre = re.sub(r'(?i)^audiobooks?$', '', genre)
|
||||
genre = re.sub(r'(?i)\b(hörb(uch|ücher)|hoerb(uch|uecher))\b', '', genre)
|
||||
genre = re.sub(r'(?i)^(hörspiele?|hoerspiele?)$', '', genre)
|
||||
genre = re.sub(r'(?i)^spoken\s*word$', '', genre)
|
||||
genre = re.sub(r'(?i)gesprochene\s*inhalte$', '', genre)
|
||||
genre = re.sub(r'(?i)^speech$', '', genre)
|
||||
genre = re.sub(r'Fanasie', 'Fantasie', genre)
|
||||
genre = re.sub(r'Mit Videospiel', 'Videospielbezug', genre)
|
||||
genre = re.sub(r'(?i)nonfiction', 'NonFiction', genre)
|
||||
|
||||
genre = re.sub(r'(?i)^speech$', '', genre)
|
||||
genre = re.sub(r'(?i)^vocal$', '', genre)
|
||||
genre = re.sub(r'(?i)^retail$', '', genre)
|
||||
genre = re.sub(r'(?i)^other$', '', genre)
|
||||
genre = re.sub(r'(?i)^misc$', '', genre)
|
||||
|
||||
genre = re.sub(r'(?i)^(sci[- ]?fi|sf)$', 'Science Fiction', genre)
|
||||
genre = re.sub(r'(?i)^\(?science fiction\)?$', 'Science Fiction', genre)
|
||||
genre = re.sub(r'Thrillers', 'Thriller', genre)
|
||||
genre = re.sub(r"(?i)\bchildren'?s audiobooks?\b", "",genre)
|
||||
genre = re.sub(r'^-\s*|\s*-$', '', genre)
|
||||
genre = re.sub(r'(?i)Engelske\s*\sLydbånd','',genre)
|
||||
genre = re.sub(r'^FIC028000.*$', '', genre)
|
||||
genre = re.sub(r'^800.*$', '', genre)
|
||||
genre = re.sub(r'^Fantastic Beasts.*$', '', genre)
|
||||
genre = re.sub(r'^and thei.*$', '', genre)
|
||||
genre = re.sub(r'^those you.*$', '', genre)
|
||||
genre = genre.strip()
|
||||
genre = genre.lstrip()
|
||||
|
||||
if "," in genre:
|
||||
genre = genre.split(",")
|
||||
elif " - " in genre:
|
||||
genre = genre.split(" - ")
|
||||
elif "Historical Adventure Thriller Mystery" in genre:
|
||||
genre = genre.split(" ")
|
||||
|
||||
if isinstance(genre, str):
|
||||
if len(genre) > 50 or (5 > len(genre) > 0):
|
||||
genre = ""
|
||||
print(f"Too long or too short: {genre}")
|
||||
|
||||
genre = genre.strip()
|
||||
genre = genre.lstrip()
|
||||
|
||||
new_genres.append(genre)
|
||||
elif isinstance(genre, list):
|
||||
if len(genres) > 1:
|
||||
genres = [g for g in genres if g]
|
||||
new_genres.extend(genre)
|
||||
|
||||
new_genres = sorted(set(new_genres))
|
||||
|
||||
print(f"After sed: {new_genres}")
|
||||
|
||||
list_difference = False
|
||||
print("Write to Database")
|
||||
if len(new_genres) < len(genres):
|
||||
list_difference = True
|
||||
for old,new in zip(genres,new_genres):
|
||||
if old != new:
|
||||
list_difference = True
|
||||
break
|
||||
|
||||
if not list_difference:
|
||||
print("No difference: Dont Commit to Database")
|
||||
continue
|
||||
|
||||
if args.print_only:
|
||||
print("Print only: Dont Commit to database")
|
||||
continue
|
||||
|
||||
print("Write to Database")
|
||||
|
||||
|
||||
print("=================================")
|
||||
cur.execute(
|
||||
"UPDATE books SET genres=? WHERE id=?",
|
||||
(json.dumps(new_genres), book_id),
|
||||
)
|
||||
print("Database Updated")
|
||||
|
||||
db.commit()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -6,15 +6,6 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
AUDIO_EXTENSIONS = {
|
||||
".mp3",
|
||||
".m4b",
|
||||
".m4a",
|
||||
}
|
||||
|
||||
RED = "\033[31m"
|
||||
GREEN = "\033[32m"
|
||||
RESET = "\033[0m"
|
||||
USE_KID3 = True
|
||||
def normalize(text: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]", "", text.lower())
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
ROOT="${1:-.}"
|
||||
|
||||
find "$ROOT" -type d | while read -r dir; do
|
||||
first_file=$(
|
||||
find "$dir" -maxdepth 1 -type f \
|
||||
\( -iname "*.m4b" -o -iname "*.m4a" -o -iname "*.mp3" \
|
||||
-o -iname "*.flac" -o -iname "*.ogg" \) |
|
||||
sort |
|
||||
head -n1
|
||||
)
|
||||
|
||||
[[ -z "$first_file" ]] && continue
|
||||
|
||||
artist=$(kid3-cli -c "get artist" "$first_file" 2>/dev/null)
|
||||
album=$(kid3-cli -c "get album" "$first_file" 2>/dev/null)
|
||||
composer=$(kid3-cli -c "get composer" "$first_file" 2>/dev/null)
|
||||
comment=$(kid3-cli -c "get comment" "$first_file" 2>/dev/null)
|
||||
echo "====≠==============================="
|
||||
echo "Folder: $(basename "$dir")"
|
||||
echo "Album: $album"
|
||||
echo "Artist: $artist"
|
||||
echo "Narrator: $composer"
|
||||
echo "Comment: $comment"
|
||||
done
|
||||
|
||||
@@ -27,7 +27,7 @@ fi
|
||||
list="$1"
|
||||
|
||||
while IFS= read -r file; do
|
||||
|
||||
book
|
||||
[[ -z "$file" ]] && continue
|
||||
|
||||
if [[ ! -e "$file" ]]; then
|
||||
|
||||
@@ -41,7 +41,7 @@ for file in "${FILES[@]}"; do
|
||||
[[ -z "$genre" ]] && continue
|
||||
cleaned=$(printf '%s\n' "$genre" | sed -E '
|
||||
s/[Aa]udio[[:space:]]*[Bb]ooks?//g
|
||||
s/\b(Hörb(uch|ücher)|Hoerb(uch|uecher)|Hörspiel(e)?|Hoerspiel(e)?)\b//Ig
|
||||
s/-?(Hörb(uch|ücher)|Hoerb(uch|uecher)|Hörspiel(e)?|Hoerspiel(e)?)\b//Ig
|
||||
|
||||
s/[Gg]esprochene[[:space:]]*[Ii]nhalte//g
|
||||
s/[Ss]poken[[:space:]]*[Ww]ord//g
|
||||
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Usage: $0 <dir1> <dir2> [del]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dir1="$1"
|
||||
dir2="$2"
|
||||
|
||||
find "$dir1" -mindepth 2 -maxdepth 2 -type d -printf '%P\n' |
|
||||
while IFS= read -r rel; do
|
||||
if [[ -d "$dir2/$rel" ]]; then
|
||||
# Exists in both
|
||||
if [[ "$3" == "del" ]]; then
|
||||
echo "Deleting: $dir1/$rel"
|
||||
rm -rf -- "$dir1/$rel"
|
||||
|
||||
# Remove empty author directory
|
||||
author="${rel%%/*}"
|
||||
rmdir --ignore-fail-on-non-empty "$dir1/$author" 2>/dev/null
|
||||
fi
|
||||
else
|
||||
# Only exists in $1
|
||||
echo "$rel"
|
||||
fi
|
||||
done
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/bin/bash
|
||||
USER="lucas"
|
||||
DEST="~/Downloads"
|
||||
|
||||
get_config() {
|
||||
while [[ ${1:0:1} == - ]]; do
|
||||
echo "Param: $1"
|
||||
[[ $1 == -h ]] && {
|
||||
if [ "$2" == "" ]; then echo "Too few arguments"; usage; exit; fi
|
||||
HOST="$2"
|
||||
shift 2;
|
||||
continue;
|
||||
};
|
||||
[[ $1 == -f ]] && {
|
||||
if [ "$2" == "" ]; then echo "Too few arguments"; usage; exit; fi
|
||||
file="$2"
|
||||
shift 2;
|
||||
continue;
|
||||
};
|
||||
[[ $1 == -o ]] && {
|
||||
if [ "$2" == "" ]; then echo "Too few arguments"; usage; exit; fi
|
||||
opt="$2"
|
||||
shift 2;
|
||||
continue;
|
||||
};
|
||||
[[ $1 == -d ]] && {
|
||||
if [ "$2" == "" ]; then echo "Too few arguments"; usage; exit; fi
|
||||
DEST="$2"
|
||||
shift 2;
|
||||
continue;
|
||||
};
|
||||
[[ $1 == -p ]] && {
|
||||
DEST="~/Videos/.stuff"
|
||||
shift 1;
|
||||
continue;
|
||||
};
|
||||
[[ $1 == --help ]] && {
|
||||
usage
|
||||
exit
|
||||
};
|
||||
echo "Parameter $1 discarded"
|
||||
usage
|
||||
exit
|
||||
done
|
||||
}
|
||||
usage() {
|
||||
printf "Usage:
|
||||
-h <hostname>\tRemote Hostname
|
||||
-f <filename>\tPath of local file
|
||||
-o <options>\tGive additional Options
|
||||
-d <dest>\tChoose other Destination Folder
|
||||
-p\t\tChange Destination
|
||||
--help\t\tDisplay Usage\n"
|
||||
exit
|
||||
}
|
||||
get_config "$@"
|
||||
if [ "${HOST}" == "deluge" ]; then
|
||||
HOST="serverhost"
|
||||
USER="deluge-user"
|
||||
DEST="/home/deluge-user/Downloads/Torrents/"
|
||||
fi
|
||||
echo "Copy ${file} to ${HOST}:${DEST}"
|
||||
scp -r ${opt} "${file}" ${USER}@${HOST}:${DEST}
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
scp lucas@serverhost:/etc/update-motd.d/10-help-text ~/Documents/Dokumente/Dokumente/Projekte/Server/motd.serverhost.10-help-text
|
||||
scp lucas@raspberry:~/.profile ~/Documents/Dokumente/Dokumente/Projekte/Server/motd.raspberry.profile
|
||||
rm 0
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/expect -f
|
||||
#Copy Crontab of raspberry and serverhost
|
||||
set host "raspberry"
|
||||
array set user { 0 "root" 1 "lucas"}
|
||||
set port "1160"
|
||||
for {set j 0} {$j < 2} {incr j 1} {
|
||||
spawn ssh -p $port lucas@$host
|
||||
expect {
|
||||
"No route to host" {
|
||||
puts "spawn ssh -p $port SendEnv=TERM lucas@$host failed."
|
||||
exit 0
|
||||
}
|
||||
"Login Sucsessfull" {
|
||||
if {$user($j)=="root"} {
|
||||
send "sudo crontab -l >> crontab.buf\r"
|
||||
expect "lucas: "
|
||||
send "Gina\&1994\&Nonna\r"
|
||||
} else {
|
||||
send "crontab -l >> crontab.buf\r"
|
||||
}
|
||||
#expect "$ "
|
||||
#send "cat crontab.buf\r"
|
||||
expect "$ "
|
||||
send "scp -P 3032 crontab.buf lucas@serverhost:~/Documents/Dokumente/Dokumente/Projekte/Server/crontab.$host.$user($j)\r"
|
||||
expect "$ "
|
||||
send "rm crontab.buf\r"
|
||||
puts "crontab.$host.$user($j) created."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/usr/bin/expect -f
|
||||
#Copy Crontab of raspberry and serverhost
|
||||
array set host { 0 "serverhost" 1 "raspberry"}
|
||||
array set user { 0 "root" 1 "lucas"}
|
||||
array set port { 0 "3032" 1 "1160"}
|
||||
|
||||
for {set i 0} {$i < 2} {incr i 1} {
|
||||
for {set j 0} {$j < 2} {incr j 1} {
|
||||
spawn ssh -p $port($i) lucas@$host($i)
|
||||
expect {
|
||||
"No route to host" {
|
||||
puts "spawn ssh -p $port($i) lucas@$host($i) failed."
|
||||
exit 0
|
||||
}
|
||||
"Login Sucsessfull" {
|
||||
if {$user($j)=="root"} {
|
||||
send "sudo crontab -l >> crontab.buf\r"
|
||||
expect "lucas: "
|
||||
send "Gina\&1994\&Nonna\r"
|
||||
} else {
|
||||
send "crontab -l >> crontab.buf\r"
|
||||
}
|
||||
#expect "$ "
|
||||
#send "cat crontab.buf\r"
|
||||
expect "$ "
|
||||
send "scp -P 1160 crontab.buf lucas@workhost:~/Documents/Dokumente/Dokumente/Projekte/Server/crontab.$host($i).$user($j)\r"
|
||||
expect "$ "
|
||||
send "rm crontab.buf\r"
|
||||
puts "crontab.$host($i).$user($j) created."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,8 @@
|
||||
[Desktop Entry]
|
||||
Name=Wallpaper Changer
|
||||
Comment=
|
||||
Exec=/home/lucas/scripts/utils/WallpaperChanger.sh -c
|
||||
Icon=/home/lucas/scripts/desktop_apps/wallpaper.png
|
||||
Terminal=false
|
||||
Type=Application
|
||||
StartupNotify=true
|
||||
@@ -0,0 +1,8 @@
|
||||
[Desktop Entry]
|
||||
Name=Wallpaper Changer
|
||||
Comment=
|
||||
Exec=/home/lucas/scripts/utils/WallpaperChanger.sh -clear
|
||||
Icon=/home/lucas/scripts/desktop_apps/wallpaperClear.png
|
||||
Terminal=false
|
||||
Type=Application
|
||||
StartupNotify=true
|
||||
@@ -0,0 +1,8 @@
|
||||
[Desktop Entry]
|
||||
Name=Wallpaper Changer
|
||||
Comment=
|
||||
Exec=/home/lucas/scripts/utils/WallpaperChanger.sh -x
|
||||
Icon=/home/lucas/scripts/desktop_apps/wallpaperX.png
|
||||
Terminal=false
|
||||
Type=Application
|
||||
StartupNotify=true
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env xdg-open
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Terminal=false
|
||||
Exec=sudo systemctl hibernate
|
||||
Name=Hibernate
|
||||
Comment=Hibernate GW
|
||||
Icon=/home/lucas/Documents/Dokumente/Projekte/Icons/hibernate.png
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -3,4 +3,6 @@ cd /docker
|
||||
find . -maxdepth 3 -type f \
|
||||
\( -name "*.yaml" -o -name "*.yml" -o -name "*.conf" -o -name "*.sh" \) \
|
||||
-print0 2>/dev/null |
|
||||
|
||||
rsync -aR --files-from=- --from0 ./ /home/lucas/scripts/docker_compose/$(hostname)
|
||||
crontab -l > /home/lucas/scripts/docker_compose/$(hostname)/crontab.bak
|
||||
Executable
+222
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
import requests
|
||||
import re
|
||||
import time
|
||||
|
||||
# ============================================================================
|
||||
# CONFIG
|
||||
# ============================================================================
|
||||
ip_address = "192.168.0.4"
|
||||
INSTANCES = [
|
||||
{
|
||||
"name": "radarr-de",
|
||||
"url": f"http://{ip_address}:7879",
|
||||
"api_key": "29f67f0ac8894357b22ea3f7fc1abfdf",
|
||||
},
|
||||
{
|
||||
"name": "radarr-en",
|
||||
"url": f"http://{ip_address}:7878",
|
||||
"api_key": "4332aee075c748f2acee41aa26f06007",
|
||||
},
|
||||
{
|
||||
"name": "sonarr",
|
||||
"url": f"http://{ip_address}:8990",
|
||||
"api_key": "707d4fcbb3d74822ba990c6a8b7278e9",
|
||||
},
|
||||
{
|
||||
"name": "lidarr",
|
||||
"url": f"http://{ip_address}:8686",
|
||||
"api_key": "bb41cbf2b17e439290f90ecfaeb24dff",
|
||||
},
|
||||
# {
|
||||
# "name": "listenarr",
|
||||
# "url": f"http://{ip_address}:7878",
|
||||
# "api_key": "READARR_API_KEY",
|
||||
# },
|
||||
]
|
||||
|
||||
PRIVATE_CLIENT = "qbittorrent-private"
|
||||
PUBLIC_CLIENT = "qbittorrent-public"
|
||||
|
||||
PRIVATE_INDEXER_REGEX = re.compile(
|
||||
r"(Bittorrentfiles|NordicQuality|funzone|SeedCore|The Brothers)",
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
DRY_RUN = False
|
||||
|
||||
# ============================================================================
|
||||
|
||||
session = requests.Session()
|
||||
|
||||
retry_strategy = Retry(
|
||||
total=3,
|
||||
connect=3,
|
||||
read=3,
|
||||
backoff_factor=2,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
allowed_methods=["GET", "PUT", "POST"],
|
||||
)
|
||||
|
||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||
|
||||
session.mount("http://", adapter)
|
||||
session.mount("https://", adapter)
|
||||
|
||||
|
||||
def get_api_version(base_url, api_key):
|
||||
r = requests.get(
|
||||
f"{base_url}/api",
|
||||
headers={"X-Api-Key": api_key},
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["current"]
|
||||
|
||||
def api_get(base_url, api_key, endpoint, retries=3):
|
||||
last_error = None
|
||||
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{base_url}{endpoint}",
|
||||
headers={"X-Api-Key": api_key},
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
except requests.HTTPError as e:
|
||||
print("Status:", e.response.status_code)
|
||||
print("Response:", e.response.text)
|
||||
raise
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
print(f"GET failed ({attempt}/{retries}): {e}")
|
||||
|
||||
if attempt < retries:
|
||||
time.sleep(2 ** attempt)
|
||||
|
||||
raise last_error
|
||||
|
||||
|
||||
def api_put(base_url, api_key, endpoint, payload, retries=3):
|
||||
last_error = None
|
||||
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
r = requests.put(
|
||||
f"{base_url}{endpoint}",
|
||||
headers={
|
||||
"X-Api-Key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json() if r.content else None
|
||||
except requests.HTTPError as e:
|
||||
print("Status:", e.response.status_code)
|
||||
print("Response:", e.response.text)
|
||||
raise
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
print(f"PUT failed ({attempt}/{retries}): {e}")
|
||||
|
||||
if attempt < retries:
|
||||
time.sleep(2 ** attempt)
|
||||
|
||||
raise last_error
|
||||
|
||||
def get_download_client_ids(base_url, endpoint,api_key):
|
||||
clients = api_get(base_url, api_key, endpoint)
|
||||
|
||||
private_id = None
|
||||
public_id = None
|
||||
|
||||
for client in clients:
|
||||
if client["name"] == PRIVATE_CLIENT:
|
||||
private_id = client["id"]
|
||||
|
||||
if client["name"] == PUBLIC_CLIENT:
|
||||
public_id = client["id"]
|
||||
|
||||
return private_id, public_id
|
||||
|
||||
|
||||
def process_instance(instance):
|
||||
name = instance["name"]
|
||||
url = instance["url"]
|
||||
api_key = instance["api_key"]
|
||||
|
||||
print(f"\n{'=' * 80}")
|
||||
print(f"Processing: {name}")
|
||||
print(f"{'=' * 80}")
|
||||
|
||||
api_version = get_api_version(url, api_key)
|
||||
endpoint_download = f"/api/{api_version}/downloadclient"
|
||||
|
||||
private_id, public_id = get_download_client_ids(url, endpoint_download, api_key)
|
||||
|
||||
if not private_id:
|
||||
print(f"ERROR: '{PRIVATE_CLIENT}' not found")
|
||||
return
|
||||
|
||||
if not public_id:
|
||||
print(f"ERROR: '{PUBLIC_CLIENT}' not found")
|
||||
return
|
||||
|
||||
print(f"{PRIVATE_CLIENT} id: {private_id}")
|
||||
print(f"{PUBLIC_CLIENT} id: {public_id}")
|
||||
|
||||
indexers = api_get(url,api_key,f"/api/{api_version}/indexer")
|
||||
for indexer in indexers:
|
||||
idx_id = indexer["id"]
|
||||
idx_name = indexer["name"]
|
||||
print(idx_name)
|
||||
|
||||
if PRIVATE_INDEXER_REGEX.search(idx_name):
|
||||
target_id = private_id
|
||||
target_name = PRIVATE_CLIENT
|
||||
else:
|
||||
target_id = public_id
|
||||
target_name = PUBLIC_CLIENT
|
||||
|
||||
current_id = indexer.get("downloadClientId")
|
||||
|
||||
if current_id == target_id:
|
||||
print(f"SKIP {idx_name} -> {target_name}")
|
||||
continue
|
||||
|
||||
indexer["downloadClientId"] = target_id
|
||||
|
||||
if DRY_RUN:
|
||||
print(f"DRY {idx_name} -> {target_name}")
|
||||
continue
|
||||
endpoint_indexer = f"/api/{api_version}/indexer/{idx_id}?forceSave=true"
|
||||
|
||||
try:
|
||||
api_put(
|
||||
url,
|
||||
api_key,
|
||||
endpoint_indexer,
|
||||
indexer,
|
||||
)
|
||||
|
||||
print(f"SET {idx_name} -> {target_name}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"FAIL {idx_name}: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
for instance in INSTANCES:
|
||||
process_instance(instance)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
CONTAINER="${1:-}"
|
||||
COMPOSE_FILE="${2:-}"
|
||||
|
||||
if [ -z "$CONTAINER" ]; then
|
||||
echo "Usage:"
|
||||
echo " $0 <container-name> [docker-compose.yml]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
OUTDIR="/tmp/${CONTAINER}-debug-${TIMESTAMP}"
|
||||
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
echo "[*] Collecting debug data for container: $CONTAINER"
|
||||
|
||||
# --------------------------------------------------
|
||||
# Docker info
|
||||
# --------------------------------------------------
|
||||
|
||||
docker inspect "$CONTAINER" \
|
||||
> "$OUTDIR/docker-inspect.json" 2>&1 || true
|
||||
|
||||
docker inspect "$CONTAINER" --format '{{.Config.Image}}' \
|
||||
> "$OUTDIR/image.txt" 2>&1 || true
|
||||
|
||||
# --------------------------------------------------
|
||||
# Logs
|
||||
# --------------------------------------------------
|
||||
|
||||
docker logs "$CONTAINER" --tail=10000 \
|
||||
> "$OUTDIR/container.log" 2>&1 || true
|
||||
|
||||
# --------------------------------------------------
|
||||
# Environment
|
||||
# --------------------------------------------------
|
||||
|
||||
docker exec "$CONTAINER" env \
|
||||
> "$OUTDIR/container-env.txt" 2>&1 || true
|
||||
|
||||
# --------------------------------------------------
|
||||
# Host information
|
||||
# --------------------------------------------------
|
||||
|
||||
{
|
||||
echo "=== HOST ==="
|
||||
|
||||
if command -v lsb_release >/dev/null 2>&1; then
|
||||
lsb_release -a
|
||||
fi
|
||||
|
||||
uname -a
|
||||
|
||||
echo
|
||||
echo "=== DOCKER ==="
|
||||
docker --version
|
||||
|
||||
} > "$OUTDIR/host.txt" 2>&1
|
||||
|
||||
PROGRAMS=(
|
||||
"yt-dlp"
|
||||
"ffmpeg"
|
||||
"ffprobe"
|
||||
"node"
|
||||
"npm"
|
||||
"python"
|
||||
"python3"
|
||||
"java"
|
||||
"curl"
|
||||
"wget"
|
||||
"git"
|
||||
)
|
||||
|
||||
# --------------------------------------------------
|
||||
# Common program versions
|
||||
# --------------------------------------------------
|
||||
|
||||
{
|
||||
echo "=== PROGRAM VERSIONS ==="
|
||||
echo
|
||||
|
||||
for prog in "${PROGRAMS[@]}"; do
|
||||
echo "----- $prog -----"
|
||||
|
||||
if docker exec "$CONTAINER" sh -c "command -v '$prog'" \
|
||||
>/dev/null 2>&1; then
|
||||
|
||||
docker exec "$CONTAINER" sh -c "
|
||||
$prog --version 2>/dev/null ||
|
||||
$prog -version 2>/dev/null ||
|
||||
$prog -V 2>/dev/null ||
|
||||
true
|
||||
" | head -n 5
|
||||
|
||||
else
|
||||
echo "not installed"
|
||||
fi
|
||||
|
||||
echo
|
||||
done
|
||||
|
||||
} > "$OUTDIR/versions.txt" 2>&1
|
||||
|
||||
# --------------------------------------------------
|
||||
# Compose snippet
|
||||
# --------------------------------------------------
|
||||
|
||||
if [ -n "$COMPOSE_FILE" ] && [ -f "$COMPOSE_FILE" ]; then
|
||||
|
||||
SERVICE=$(docker inspect "$CONTAINER" \
|
||||
--format '{{ index .Config.Labels "com.docker.compose.service" }}' \
|
||||
2>/dev/null)
|
||||
|
||||
if [ -n "$SERVICE" ]; then
|
||||
yq ".services.\"$SERVICE\"" "$COMPOSE_FILE" \
|
||||
> "$OUTDIR/compose-service.yaml" 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# --------------------------------------------------
|
||||
# Summary
|
||||
# --------------------------------------------------
|
||||
|
||||
{
|
||||
echo "================================="
|
||||
echo "Debug Summary"
|
||||
echo "================================="
|
||||
echo
|
||||
|
||||
echo "Container:"
|
||||
echo " $CONTAINER"
|
||||
echo
|
||||
|
||||
echo "Image:"
|
||||
cat "$OUTDIR/image.txt" 2>/dev/null
|
||||
echo
|
||||
|
||||
echo "Host:"
|
||||
uname -a
|
||||
lsb_release -a
|
||||
docker --version
|
||||
echo
|
||||
|
||||
echo "Compose Service:"
|
||||
|
||||
docker inspect "$CONTAINER" \
|
||||
--format '{{ index .Config.Labels "com.docker.compose.service" }}' \
|
||||
2>/dev/null || echo "unknown"
|
||||
|
||||
if [ -n "$SERVICE" ]; then
|
||||
echo "Docker Compose Config"
|
||||
yq ".services.\"$SERVICE\"" "$COMPOSE_FILE"
|
||||
fi
|
||||
echo
|
||||
echo "Programs found:"
|
||||
|
||||
for prog in "${PROGRAMS[@]}"; do
|
||||
if docker exec "$CONTAINER" sh -c "command -v '$prog'" \
|
||||
>/dev/null 2>&1; then
|
||||
echo "$prog: $(
|
||||
$prog --version 2>/dev/null ||
|
||||
$prog -version 2>/dev/null ||
|
||||
$prog -V 2>/dev/null ||
|
||||
true
|
||||
)" | head -n1
|
||||
fi
|
||||
done
|
||||
|
||||
} | tee "$OUTDIR/summary.txt"
|
||||
|
||||
# --------------------------------------------------
|
||||
# Archive
|
||||
# --------------------------------------------------
|
||||
|
||||
ARCHIVE="${OUTDIR}.tar.gz"
|
||||
|
||||
tar czf "$ARCHIVE" \
|
||||
-C "$(dirname "$OUTDIR")" \
|
||||
"$(basename "$OUTDIR")"
|
||||
|
||||
echo
|
||||
echo "[OK] Debug report created:"
|
||||
echo "$ARCHIVE"
|
||||
|
||||
echo
|
||||
ls -lh "$ARCHIVE"
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
/usr/bin/docker compose -f /docker/nginx/docker-compose.yaml run --rm certbot renew && \
|
||||
/usr/bin/docker compose -f /docker/nginx/docker-compose.yaml exec nginx nginx -s reload
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
/usr/bin/docker exec -u www-data nextcloud /usr/local/bin/php -f /var/www/html/cron.php
|
||||
@@ -0,0 +1,39 @@
|
||||
# Edit this file to introduce tasks to be run by cron.
|
||||
#
|
||||
# Each task to run has to be defined through a single line
|
||||
# indicating with different fields when the task will be run
|
||||
# and what command to run for the task
|
||||
#
|
||||
# To define the time you can provide concrete values for
|
||||
# minute (m), hour (h), day of month (dom), month (mon),
|
||||
# and day of week (dow) or use '*' in these fields (for 'any').
|
||||
#
|
||||
# Notice that tasks will be started based on the cron's system
|
||||
# daemon's notion of time and timezones.
|
||||
#
|
||||
# Output of the crontab jobs (including errors) is sent through
|
||||
# email to the user the crontab file belongs to (unless redirected).
|
||||
#
|
||||
# For example, you can run a backup of all your user accounts
|
||||
# at 5 a.m every week with:
|
||||
# 0 5 * * 1 tar -zcf /var/backups/home.tgz /home/
|
||||
#
|
||||
# For more information see the manual pages of crontab(5) and cron(8)
|
||||
#
|
||||
# Example of job definition:
|
||||
# .---------------- minute (0 - 59)
|
||||
# | .------------- hour (0 - 23)
|
||||
# | | .---------- day of month (1 - 31)
|
||||
# | | | .------- month (1 - 12) OR jan,feb,mar,apr ...
|
||||
# | | | | .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
|
||||
# | | | | |
|
||||
# * * * * * user-name command to be executed
|
||||
|
||||
*/5 * * * * /home/lucas/scripts/docker_compose/docker_update_nextcloud.sh && logger "CRON_SUCCESS nextcloud" || logger "CRON_FAILURE nextcloud"
|
||||
*/5 * * * * /home/lucas/scripts/docker_compose/node-exporter-pubip-docker-states.sh && logger "CRON_SUCCESS pubip" || logger "CRON_FAILURE pubip"
|
||||
0 * * * * /home/lucas/scripts/media_editing/convert_heic.sh && logger "CRON_SUCCESS conv_heic" || logger "CRON_FAILURE conv_heic"
|
||||
0 * * * * /home/lucas/scripts/file_processing/rename_memes_folder.sh && logger "CRON_SUCCESS rename_mem" || logger "CRON_FAILURE rename_mem"
|
||||
10 * * * * /home/lucas/scripts/file_processing/rename_camera_folder.py && logger "CRON_SUCCESS rename_cam" || logger "CRON_FAILURE rename_cam"
|
||||
0 3 * * * /home/lucas/scripts/docker_compose/docker_renew_certbot.sh && logger "CRON_SUCCESS renew_cert" || logger "CRON_FAILURE renew_cert"
|
||||
0 23 * * * /home/lucas/scripts/file_processing/sync_camera_to_immich.sh && logger "CRON_SUCCESS immich" || logger "CRON_FAILURE immich"
|
||||
0 3 * * 0 /home/lucas/scripts/utils/download_latest_ubuntu.sh && logger "CRON_SUCCESS ubuntu" || logger "CRON_FAILURE ubuntu"
|
||||
@@ -17,6 +17,11 @@ services:
|
||||
DIUN_PROVIDERS_DOCKER: "true"
|
||||
DIUN_PROVIDERS_DOCKER_WATCHBYDEFAULT: "true"
|
||||
|
||||
# Metrics
|
||||
|
||||
DIUN_METRICS_ENABLED: "true"
|
||||
DIUN_METRICS_ADDR: ":9090"
|
||||
DIUN_METRICS_TOKEN: "Qj#8C!@Uz3uRWm@mCd@KH6Bo7nfQ4K"
|
||||
# Email notifications
|
||||
DIUN_NOTIF_MAIL_HOST: smtp.web.de
|
||||
DIUN_NOTIF_MAIL_PORT: 587
|
||||
@@ -30,4 +35,9 @@ services:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
networks:
|
||||
- shared-prometheus-services
|
||||
networks:
|
||||
shared-prometheus-services:
|
||||
external: true
|
||||
|
||||
@@ -16,4 +16,10 @@ services:
|
||||
- /data/gitea:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
|
||||
networks:
|
||||
- default
|
||||
- shared-proxy-services
|
||||
networks:
|
||||
default:
|
||||
shared-proxy-services:
|
||||
external: true
|
||||
@@ -9,6 +9,11 @@ services:
|
||||
TZ: Europe/Vienna
|
||||
volumes:
|
||||
- /srv/homeassistant:/config
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
|
||||
mosquitto:
|
||||
container_name: mosquitto
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#
|
||||
# WARNING: To install Immich, follow our guide: https://docs.immich.app/install/docker-compose
|
||||
#
|
||||
# Make sure to use the docker-compose.yml of the current release:
|
||||
#
|
||||
# https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
|
||||
#
|
||||
# The compose file on main may not be compatible with the latest release.
|
||||
|
||||
name: immich
|
||||
|
||||
services:
|
||||
immich-server:
|
||||
container_name: immich_server
|
||||
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
|
||||
# extends:
|
||||
# file: hwaccel.transcoding.yml
|
||||
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
|
||||
volumes:
|
||||
# Do not edit the next line. If you want to change the media storage location on your system, edit the value of UPLOAD_LOCATION in the .env file
|
||||
- ${UPLOAD_LOCATION}:/data
|
||||
- /data/immich/pictures:/libary
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- '2283:2283'
|
||||
depends_on:
|
||||
- database
|
||||
restart: always
|
||||
healthcheck:
|
||||
disable: false
|
||||
networks:
|
||||
- immich
|
||||
- shared-redis-services
|
||||
|
||||
immich-machine-learning:
|
||||
container_name: immich_machine_learning
|
||||
# For hardware acceleration, add one of -[armnn, cuda, rocm, openvino, rknn] to the image tag.
|
||||
# Example tag: ${IMMICH_VERSION:-release}-cuda
|
||||
image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
|
||||
# extends: # uncomment this section for hardware acceleration - see https://docs.immich.app/features/ml-hardware-acceleration
|
||||
# file: hwaccel.ml.yml
|
||||
# service: cpu # set to one of [armnn, cuda, rocm, openvino, openvino-wsl, rknn] for accelerated inference - use the `-wsl` version for WSL2 where applicable
|
||||
volumes:
|
||||
- model-cache:/cache
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
healthcheck:
|
||||
disable: false
|
||||
networks:
|
||||
- immich
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||
POSTGRES_INITDB_ARGS: '--data-checksums'
|
||||
# Uncomment the DB_STORAGE_TYPE: 'HDD' var if your database isn't stored on SSDs
|
||||
# DB_STORAGE_TYPE: 'HDD'
|
||||
volumes:
|
||||
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
|
||||
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
||||
shm_size: 128mb
|
||||
restart: always
|
||||
healthcheck:
|
||||
disable: false
|
||||
networks:
|
||||
- immich
|
||||
|
||||
volumes:
|
||||
model-cache:
|
||||
networks:
|
||||
immich:
|
||||
shared-redis-services:
|
||||
external: true
|
||||
|
||||
@@ -15,7 +15,6 @@ services:
|
||||
networks:
|
||||
- default
|
||||
- shared-proxy-services
|
||||
|
||||
seerr:
|
||||
image: ghcr.io/seerr-team/seerr:latest
|
||||
container_name: seerr
|
||||
@@ -29,6 +28,7 @@ services:
|
||||
networks:
|
||||
- default
|
||||
- shared-proxy-services
|
||||
|
||||
networks:
|
||||
default:
|
||||
external: true
|
||||
|
||||
@@ -1,22 +1,5 @@
|
||||
|
||||
services:
|
||||
sonarr-en:
|
||||
image: lscr.io/linuxserver/sonarr:latest
|
||||
container_name: sonarr-en
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8989:8989"
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=Europe/Berlin
|
||||
volumes:
|
||||
- ./sonarr_EN:/config
|
||||
- /data/jellyfin/media/series-english:/JellyfinSeriesEnglish
|
||||
- /data/jellyfin/media/anime-english:/JellyfinAnimeEnglish
|
||||
- /data/jellyfin/media/documentaries-english:/JellyfinDocus
|
||||
- /data/jellyfin/buffer:/buffer
|
||||
- /torrent/qbittorrent/downloads:/downloads
|
||||
sonarr-de:
|
||||
image: lscr.io/linuxserver/sonarr:latest
|
||||
container_name: sonarr-de
|
||||
@@ -31,10 +14,14 @@ services:
|
||||
- ./sonarr_DE:/config
|
||||
- /data/jellyfin/media/series-german:/JellyfinSeriesGerman
|
||||
- /data/jellyfin/media/anime-german:/JellyfinAnimeGerman
|
||||
- /data/jellyfin/media/series-english:/JellyfinSeriesEnglish
|
||||
- /data/jellyfin/media/anime-english:/JellyfinAnimeEnglish
|
||||
- /data/jellyfin/media/documentaries-english:/JellyfinDocus
|
||||
- /data/jellyfin/media/series-kids:/JellyfinKinder
|
||||
- /data/jellyfin/buffer:/buffer
|
||||
- /torrent/qbittorrent/downloads:/downloads
|
||||
- /torrent/rundfunkarr:/downloads-rundfunkarr
|
||||
- /torrent/qbittorrent-private/downloads:/downloads-private
|
||||
- /torrent/qbittorrent-public/downloads:/downloads-public
|
||||
- /torrent/rundfunkarr-de:/downloads-rundfunkarr
|
||||
radarr-en:
|
||||
image: lscr.io/linuxserver/radarr:latest
|
||||
container_name: radarr-en
|
||||
@@ -49,7 +36,8 @@ services:
|
||||
- ./radarr_EN:/config
|
||||
- /data/jellyfin/media/movies-english:/JellyfinMoviesEnglish
|
||||
- /data/jellyfin/media/comedy-english:/JellyfinComedy
|
||||
- /torrent/qbittorrent/downloads:/downloads
|
||||
- /torrent/qbittorrent-private/downloads:/downloads-private
|
||||
- /torrent/qbittorrent-public/downloads:/downloads-public
|
||||
- /data/jellyfin/buffer:/buffer
|
||||
radarr-de:
|
||||
image: lscr.io/linuxserver/radarr:latest
|
||||
@@ -66,8 +54,9 @@ services:
|
||||
- /data/jellyfin/media/movies-german:/JellyfinMoviesGerman
|
||||
- /data/jellyfin/media/movies-kids:/JellyfinKinder
|
||||
- /data/jellyfin/buffer:/buffer
|
||||
- /torrent/qbittorrent/downloads:/downloads
|
||||
- /torrent/rundfunkarr:/downloads-rundfunkarr
|
||||
- /torrent/qbittorrent-private/downloads:/downloads-private
|
||||
- /torrent/qbittorrent-public/downloads:/downloads-public
|
||||
- /torrent/rundfunkarr-de:/downloads-rundfunkarr
|
||||
prowlarr:
|
||||
image: lscr.io/linuxserver/prowlarr:latest
|
||||
container_name: prowlarr
|
||||
@@ -80,23 +69,33 @@ services:
|
||||
- TZ=Europe/Vienna
|
||||
volumes:
|
||||
- ./prowlarr:/config
|
||||
|
||||
gluetun-rundfunkarr-de:
|
||||
image: qmcgaw/gluetun
|
||||
image: qmcgaw/gluetun:latest
|
||||
container_name: gluetun-rundfunkarr-de
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
- ./gluetun-de:/gluetun/wireguard
|
||||
|
||||
environment:
|
||||
- VPN_SERVICE_PROVIDER=custom
|
||||
- VPN_TYPE=wireguard
|
||||
ports:
|
||||
- "6767:6767" # Rundfunkarr UI
|
||||
|
||||
rundfunkarr-at:
|
||||
image: rundfunkarr:local
|
||||
container_name: rundfunkarr-at
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
- PUID=1000 # User ID (id -u)
|
||||
- PGID=1000 # Group ID (id -g)
|
||||
- DOWNLOAD_FOLDER_PATH=/downloads-rundfunkarr
|
||||
volumes:
|
||||
- ./rundfunkarr-at:/app/prisma/data
|
||||
- /torrent/rundfunkarr-at:/downloads-rundfunkarr
|
||||
ports:
|
||||
- 6768:6767
|
||||
restart: unless-stopped
|
||||
rundfunkarr-de:
|
||||
image: ghcr.io/rundfunkarr/rundfunkarr:latest
|
||||
container_name: rundfunkarr-de
|
||||
@@ -114,18 +113,14 @@ services:
|
||||
network_mode: "service:gluetun-rundfunkarr-de"
|
||||
depends_on:
|
||||
- gluetun-rundfunkarr-de
|
||||
|
||||
gluetun-at:
|
||||
image: qmcgaw/gluetun:latest
|
||||
container_name: gluetun-at
|
||||
restart: unless-stopped
|
||||
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
|
||||
ports:
|
||||
- "8888:8888" # HTTP Proxy
|
||||
- "1080:1080" # SOCKS5 Proxy
|
||||
@@ -134,10 +129,12 @@ services:
|
||||
- VPN_TYPE=wireguard
|
||||
- SOCKS5_ENABLED=on
|
||||
- HTTPPROXY=on
|
||||
- HTTP_LISTENING_ADDRESS:=8888
|
||||
- SOCKSPROXY=on
|
||||
|
||||
- TZ=Europe/Vienna
|
||||
|
||||
- DNS_SERVER=on
|
||||
- DNS_UPSTREAM_RESOLVER_TYPE=plain
|
||||
- DNS_UPSTREAM_PLAIN_ADDRESSES=1.1.1.1:53,8.8.8.8:53
|
||||
volumes:
|
||||
- ./gluetun-at:/gluetun/wireguard
|
||||
# lazylibrarian:
|
||||
@@ -154,7 +151,7 @@ services:
|
||||
# - ./lazylibrarian:/config
|
||||
# - /data/audiobookshelf/podcasts:/podcasts
|
||||
# - /data/audiobookshelf/audiobooks-englisch:/audiobooks
|
||||
# - /torrent/qbittorrent/downloads:/downloads
|
||||
# - /torrent/qbittorrent-private/downloads:/downloads
|
||||
|
||||
flaresolverr:
|
||||
image: ghcr.io/flaresolverr/flaresolverr:latest
|
||||
@@ -169,7 +166,7 @@ services:
|
||||
- "8191:8191"
|
||||
|
||||
lidarr:
|
||||
image: lscr.io/linuxserver/lidarr:latest
|
||||
image: lscr.io/linuxserver/lidarr:develop #latest
|
||||
container_name: lidarr
|
||||
environment:
|
||||
- PUID=1000
|
||||
@@ -177,8 +174,9 @@ services:
|
||||
- TZ=Europe/Berlin
|
||||
volumes:
|
||||
- ./lidarr:/config
|
||||
- /data/navidrome/music:/music
|
||||
- /torrent/qbittorrent/downloads:/downloads
|
||||
- /data/navidrome/music/music:/music
|
||||
- /torrent/qbittorrent-private/downloads:/downloads-private
|
||||
- /torrent/qbittorrent-public/downloads:/downloads-public
|
||||
|
||||
ports:
|
||||
- 8686:8686
|
||||
@@ -232,44 +230,104 @@ services:
|
||||
- /data/audiobookshelf/data/audiobooks-englisch:/audiobooks-english
|
||||
- /data/audiobookshelf/data/audiobooks-deutsch:/audiobooks-german
|
||||
- /data/audiobookshelf/data/audiobooks-kinder:/audiobooks-kinder
|
||||
- /torrent/qbittorrent/downloads:/downloads
|
||||
- /torrent/qbittorrent-public/downloads:/downloads-public
|
||||
- /torrent/qbittorrent-private/downloads:/downloads-private
|
||||
restart: unless-stopped
|
||||
upload-assistant:
|
||||
image: ghcr.io/audionut/upload-assistant:latest
|
||||
container_name: upload-assistant
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5000:5000"
|
||||
jackett:
|
||||
image: lscr.io/linuxserver/jackett:latest
|
||||
container_name: jackett
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
# Paths visible in the file browser
|
||||
- UA_BROWSE_ROOTS=/downloads,/data
|
||||
|
||||
command: ["--webui", "0.0.0.0:5000"]
|
||||
|
||||
- TZ=Europe/Vienna
|
||||
volumes:
|
||||
- ./upload-assistant:/Upload-Assistant/data
|
||||
# Sonarr/Radarr libraries
|
||||
- /data/jellyfin/media/series-english:/data/series-english:ro
|
||||
- /data/jellyfin/media/anime-english:/data/anime-english:ro
|
||||
- /data/jellyfin/media/documentaries-english:/docus-english:ro
|
||||
- /data/jellyfin/media/series-german:/series-german:ro
|
||||
- /data/jellyfin/media/anime-german:/anime-german:ro
|
||||
- /data/jellyfin/media/series-kids:/series-kids:ro
|
||||
- /data/jellyfin/media/movies-english:/data/movies-english:ro
|
||||
- /data/jellyfin/media/comedy-english:/data/comedy-english:ro
|
||||
- /data/jellyfin/media/movies-german:/data/movies-german:ro
|
||||
- /data/jellyfin/media/movies-kids:/data/movies-kids:ro
|
||||
# - /data/audiobookshelf/podcasts:/podcasts
|
||||
# - /data/audiobookshelf/audiobooks-englisch:/audiobooks
|
||||
- /data/navidrome/music:/data/music:ro
|
||||
- /data/audiobookshelf/data/audiobooks-englisch:/data/audiobooks-english:ro
|
||||
- /data/audiobookshelf/data/audiobooks-deutsch:/data/audiobooks-german:ro
|
||||
- /data/audiobookshelf/data/audiobooks-kinder:/data/audiobooks-kinder:ro
|
||||
# Download location
|
||||
- /torrent/qbittorrent/downloads:/downloads
|
||||
|
||||
- ./jackett/config:/config
|
||||
- ./jackett/downloads:/downloads
|
||||
ports:
|
||||
- "9117:9117"
|
||||
restart: unless-stopped
|
||||
gluetun-proxy:
|
||||
image: qmcgaw/gluetun:latest
|
||||
container_name: gluetun-proxy
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
ports:
|
||||
- "8889:8888" # HTTP proxy
|
||||
- "8388:8388" # SOCKS5 proxy
|
||||
volumes:
|
||||
- ./gluetun-proxy:/gluetun/wireguard
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
environment:
|
||||
- VPN_SERVICE_PROVIDER=custom
|
||||
- VPN_TYPE=wireguard
|
||||
- SOCKS5_ENABLED=on
|
||||
- HTTPPROXY=on
|
||||
- SOCKSPROXY=on
|
||||
restart: unless-stopped
|
||||
cleanuparr:
|
||||
image: ghcr.io/cleanuparr/cleanuparr:latest
|
||||
container_name: cleanuparr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "11011:11011"
|
||||
volumes:
|
||||
- ./cleanuparr:/config
|
||||
- /torrent/qbittorrent-private/downloads:/downloads-private
|
||||
- /torrent/qbittorrent-public/downloads:/downloads-public
|
||||
- /torrent/qbittorrent-private/orphaned:/orphaned
|
||||
environment:
|
||||
- PORT=11011
|
||||
- BASE_PATH=
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- UMASK=022
|
||||
- TZ=Etc/UTC
|
||||
unpackerr:
|
||||
image: golift/unpackerr
|
||||
container_name: unpackerr
|
||||
volumes:
|
||||
- /torrent/qbittorrent-private/downloads:/downloads-private
|
||||
- ./unpackerr:/config
|
||||
restart: always
|
||||
user: 1000:1000
|
||||
environment:
|
||||
- TZ=America/New_York
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
## Global Settings
|
||||
- UN_DEBUG=true
|
||||
- UN_LOG_FILE=/config/unpackerr.log
|
||||
- UN_LOG_FILES=10
|
||||
- UN_LOG_FILE_MB=10
|
||||
- UN_INTERVAL=10m
|
||||
- UN_PROGRESS=15s
|
||||
- UN_START_DELAY=1m
|
||||
- UN_RETRY_DELAY=5m
|
||||
- UN_MAX_RETRIES=2
|
||||
- UN_REMNANT_ACTION=rename
|
||||
- UN_PARALLEL=1
|
||||
- UN_FILE_MODE=0644
|
||||
- UN_DIR_MODE=0755
|
||||
# Sonarr
|
||||
- UN_SONARR_0_URL=http://sonarr-de:8989
|
||||
- UN_SONARR_0_API_KEY=707d4fcbb3d74822ba990c6a8b7278e9
|
||||
- UN_SONARR_0_PATHS_0=/downloads-private/tv-sonarr-de
|
||||
- UN_SONARR_0_DELETE_DELAY=5m
|
||||
# Radarr-de
|
||||
- UN_RADARR_0_URL=http://radarr-de:7878
|
||||
- UN_RADARR_0_API_KEY=29f67f0ac8894357b22ea3f7fc1abfdf
|
||||
- UN_RADARR_0_PATHS_0=/downloads-private/radarr-de
|
||||
- UN_RADARR_0_DELETE_DELAY=5m
|
||||
# Radarr-en
|
||||
- UN_RADARR_1_URL=http://radarr-en:7878
|
||||
- UN_RADARR_1_API_KEY=4332aee075c748f2acee41aa26f06007
|
||||
- UN_RADARR_1_PATHS_0=/downloads-private/radarr-en
|
||||
- UN_RADARR_1_DELETE_DELAY=5m
|
||||
# Lidarr
|
||||
- UN_LIDARR_0_URL=http://lidarr:8686
|
||||
- UN_LIDARR_0_API_KEY=bb41cbf2b17e439290f90ecfaeb24dff
|
||||
- UN_LIDARR_0_PATHS_0=/downloads-private/lidarr
|
||||
- UN_LIDARR_0_DELETE_DELAY=5m
|
||||
networks:
|
||||
default:
|
||||
external: true
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
#
|
||||
# Use this configuration with WireGuard client
|
||||
#
|
||||
[Interface]
|
||||
Address = 10.14.0.2/16
|
||||
PrivateKey = mKeDQdxtM1iFJS7WLlb2jPGabZIt3NkoA/nGCcNOU1I=
|
||||
DNS = 162.252.172.57, 149.154.159.92
|
||||
[Peer]
|
||||
PublicKey = dPZe8Jq3Hu0k07MDk+Y4+AS2XHSLYalyg91TSFXRYEA=
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 87.249.133.4:51820
|
||||
@@ -0,0 +1,566 @@
|
||||
#######################################################
|
||||
## Unpackerr Example Configuration File ##
|
||||
#######################################################
|
||||
## The values are a mix of defaults and examples. ##
|
||||
## Env vars overlay the process, not this file. ##
|
||||
## More configuration help: https://unpackerr.zip ##
|
||||
## Config Generator: https://notifiarr.com/unpackerr ##
|
||||
#######################################################
|
||||
|
||||
## Turn on debug messages in the output. Do not wrap this in quotes.
|
||||
## Recommend trying this so you know what it looks like. I personally leave it on.
|
||||
#debug = false
|
||||
|
||||
## Disable writing messages to stdout/stderr. This silences the app. Set a log
|
||||
## file below if you set this to true. Recommended when starting with systemctl.
|
||||
#quiet = false
|
||||
|
||||
## Send error output to stderr instead of stdout by setting error_stderr to true.
|
||||
## Recommend leaving this at false. Ignored if quiet (above) is true.
|
||||
#error_stderr = false
|
||||
|
||||
## Setting activity to true will silence all app queue log lines with only zeros.
|
||||
## Set this to true when you want less log spam.
|
||||
#activity = false
|
||||
|
||||
## The Starr-application activity queue is logged on an interval.
|
||||
## Adjust that interval with this setting.
|
||||
## Default is a minute. 2m, 5m, 10m, 30m, 1h are also perfectly acceptable.
|
||||
#log_queues = "1m"
|
||||
|
||||
## Write messages to a log file. This is the same data that is normally output to stdout.
|
||||
## This setting is great for Docker users that want to export their logs to a file.
|
||||
## The alternative is to use syslog to log the output of the application to a file.
|
||||
## Default is no log file; this is unset.
|
||||
## Except on macOS and Windows, the log file gets set to "~/.unpackerr/unpackerr.log"
|
||||
## log_files=0 turns off auto-rotation so logrotate can own the file.
|
||||
## After logrotate renames the live file, send SIGHUP (`systemctl reload unpackerr`)
|
||||
## to close and reopen it. Use logrotate `create` plus postrotate HUP, not copytruncate.
|
||||
## Default files is 10 and size(mb) is 10 Megabytes.
|
||||
#log_file = ''
|
||||
#log_files = 10
|
||||
#log_file_mb = 10
|
||||
#log_file_mode = "0600"
|
||||
|
||||
## How many completed or failed items to keep in unpackerr.history.jsonl
|
||||
## (next to the log file, or the config file if logs go to stdout).
|
||||
## With neither a log file nor a config file, history stays in memory
|
||||
## for this process and is not written to disk, so it does not survive
|
||||
## a restart. Set `log_file` or use a config file to persist it.
|
||||
## In-progress extracts are also written so a restart can resume them
|
||||
## for 72 hours (Starr waiting for import, folders waiting on
|
||||
## delete_after). Those in-progress rows do not count against this
|
||||
## number. Imported and failed rows do. The history API still lists
|
||||
## only completed or failed rows.
|
||||
## `0` disables history and restart resume.
|
||||
#keep_history = 400
|
||||
|
||||
## How often to poll starr apps (sonarr, radarr, etc).
|
||||
## Recommend 1m-5m. Uses Go Duration.
|
||||
#interval = "2m"
|
||||
|
||||
## How often status is logged for in-progress extractions.
|
||||
## `0` uses the default `15s`. Uses Go Duration.
|
||||
#progress = "15s"
|
||||
|
||||
## How long an item must be queued (download complete) before extraction will start.
|
||||
## One minute is the historic default and works well. Set higher if your downloads
|
||||
## take longer to finalize (or transfer locally). Uses Go Duration.
|
||||
#start_delay = "1m"
|
||||
|
||||
## How long to wait before removing the history for a failed extraction.
|
||||
## Once the history is deleted the item will be recognized as new and
|
||||
## extraction will start again. Uses Go Duration.
|
||||
#retry_delay = "5m"
|
||||
|
||||
## How many times to retry a failed extraction after the first try. Default is 2
|
||||
## (3 attempts total). `0` uses that default. Pauses retry_delay between attempts.
|
||||
#max_retries = 2
|
||||
|
||||
## Applies to Starr apps and to watched folders with `move_back` enabled
|
||||
## (the default folder config extracts into a sidecar tree and does not
|
||||
## classify leftovers there). When an extracted file cannot be moved into
|
||||
## place because the destination already exists, Unpackerr checks whether
|
||||
## that name was present before extraction. Names that arrived with the
|
||||
## download are kept; names that were not (leftovers from an interrupted
|
||||
## extraction) are handled by this setting. `rename` (default) moves the
|
||||
## leftover to a sibling named with a `.remnant` suffix and retries the
|
||||
## extract. `delete` removes it and retries. `off` leaves the leftover
|
||||
## in place and fails the extract without retrying. The item stays failed
|
||||
## until it leaves the Starr queue (folders are untracked). Bounded by
|
||||
## max_retries; if retries are exhausted the leftover fails the extract
|
||||
## instead of reporting success.
|
||||
#remnant_action = "rename"
|
||||
|
||||
## How many files may be extracted in parallel. 1 works fine.
|
||||
## Do not wrap the number in quotes. Raise this only if you have fast disks and CPU.
|
||||
#parallel = 1
|
||||
|
||||
## Extracted files are created with this mode. The process umask still
|
||||
## applies, so 0666 becomes 0644 under umask 0022. Use 0644, or 0660 or
|
||||
## 0666 when another user must write the extracted files. Archive
|
||||
## execute bits are kept only for a watched folder with preserve_exec
|
||||
## enabled.
|
||||
#file_mode = "0644"
|
||||
|
||||
## Extracted directories are created with this mode. The process umask
|
||||
## still applies.
|
||||
#dir_mode = "0755"
|
||||
|
||||
## List of passwords to use for encrypted archives. Must be a list of strings.
|
||||
## Use this special format as a password to read more passwords from a file:
|
||||
## passwords = [ "filepath:/path/to/passwords.txt" ]
|
||||
#passwords = []
|
||||
|
||||
[webserver]
|
||||
## Expose Prometheus metrics at /metrics. The HTTP server starts whenever listen_addr is set;
|
||||
## this only controls the /metrics route. Scrapes must send an API key with
|
||||
## system:metrics:read via Authorization: Bearer or X-Api-Key.
|
||||
# metrics = false
|
||||
## Expose Go pprof handlers at /debug/pprof/. Leave this false unless you
|
||||
## are debugging the running process.
|
||||
# pprof = false
|
||||
## This may be set to a port or an ip:port to bind a specific IP. 0.0.0.0 binds ALL IPs.
|
||||
## Set this to an empty string to disable the HTTP server.
|
||||
# listen_addr = "0.0.0.0:5656"
|
||||
## Local UI login. Use user:pass (hashed on startup), !!cryptd!!user:$2a$...
|
||||
## for a stored hash, webauth:X-Webauth-User for a reverse-proxy header, or
|
||||
## noauth. The HTTP API does not accept plaintext; send user:<PBKDF2 hex>
|
||||
## (same digest as login) and uiCurrentKdf when changing a live password.
|
||||
## filepath:/path reads that file for the live password and keeps filepath: in the
|
||||
## config. Empty generates a temporary password when the HTTP server is enabled.
|
||||
## Reset with --reset. Do not set an empty
|
||||
## UN_WEBSERVER_UI_PASSWORD; a present empty env value wipes a stored hash.
|
||||
ui_password = "!!cryptd!!admin:$2a$10$t8pChUtJw.VZW2DO47Lh/.2nE9J.jHnx.MH5VncjCZ2LADfso64oe"
|
||||
## Used with webauth (proxy header auth). Empty keeps those proxy users
|
||||
## as admin. Ignored for noauth and local password login. Set this to a
|
||||
## header the reverse proxy fills, for example X-Webauth-Role or
|
||||
## X-Authentik-Groups. Matching names are a built-in admin or a custom
|
||||
## [webserver.roles] name. Comma, semicolon, pipe, or space separated
|
||||
## lists are unioned; extra IdP groups are ignored. A selected header
|
||||
## that is missing, empty, or has no matching role is access denied,
|
||||
## not admin.
|
||||
## example: ui_role_header = "X-Webauth-Role"
|
||||
# ui_role_header = ""
|
||||
## Recommend setting a log file for HTTP requests. Otherwise, they go with other logs.
|
||||
# log_file = ''
|
||||
## This app automatically rotates logs. Set these to the size and number to keep.
|
||||
## log_files=0 disables auto-rotation; SIGHUP reopens the HTTP log too.
|
||||
# log_files = 10
|
||||
# log_file_mb = 10
|
||||
## Set both of these to valid file paths to enable HTTPS/TLS.
|
||||
# ssl_cert_file = ''
|
||||
# ssl_key_file = ''
|
||||
## Base URL from which to serve content.
|
||||
# urlbase = "/"
|
||||
## Upstreams should be set to the IP or CIDR of your trusted upstream proxy.
|
||||
## Setting this correctly allows X-Forwarded-For to be used in logs.
|
||||
## In the future it may control auth proxy trust. Must be a list of strings.
|
||||
## example: upstreams = [ "127.0.0.1/32", "10.1.2.0/24" ]
|
||||
# upstreams = []
|
||||
## Empty keeps the library same-origin check (the embedded UI). Add hosts
|
||||
## when a browser on another origin should open GET /ws, for example
|
||||
## localhost:5173 or https://ui.example.com. Patterns use path.Match;
|
||||
## a URI scheme is matched as scheme://host. Do not set * unless you
|
||||
## intend to allow every origin.
|
||||
## example: ws_origins = [ "localhost:5173" ]
|
||||
# ws_origins = []
|
||||
## Nested [[webserver.api_keys]] tables, not an inline array. Each key is 60–150 ASCII
|
||||
## characters. Names and key values must be unique. Built-in role `admin` grants every
|
||||
## permission. Custom roles are listed under [webserver.roles].
|
||||
## Env uses UN_WEBSERVER_API_KEYS_0_NAME, _KEY, and _ROLES_0.
|
||||
## Nested [webserver.roles.<name>] tables. Names are letters, digits, _ or -.
|
||||
## Built-in admin cannot be redefined. `*` is reserved for that built-in role.
|
||||
## Each custom role needs at least one known permission.
|
||||
## File browser is system:browse:read (list) and system:browse:write (create folder).
|
||||
## Env works but is picky: do not set UN_WEBSERVER_ROLES itself. Role names are
|
||||
## case-sensitive and may contain underscores. Index permissions from 0:
|
||||
## UN_WEBSERVER_ROLES_stats_PERMISSIONS_0=system:stats:read
|
||||
## UN_WEBSERVER_ROLES_read_only_PERMISSIONS_0=system:stats:read
|
||||
## [webserver.roles.stats]
|
||||
## permissions = ["system:stats:read"]
|
||||
|
||||
[[webserver.api_keys]]
|
||||
name = "admin"
|
||||
key = "Gv7I8Y0wY7-KFjZxeRD_mVvSm8Ca2sxvo267DY9GG5YKD34BBMGIeudHQy_tI7gq"
|
||||
roles = ["admin"]
|
||||
|
||||
## Global Folder configuration that affects all watched folders.
|
||||
[folders]
|
||||
## How many new folder events can be immediately queued. Don't change this.
|
||||
# buffer = 20000
|
||||
|
||||
###############################################################################
|
||||
##-IMPORTANT-#######-READ THIS!!!-################ Seriously, read this. ######
|
||||
###############################################################################
|
||||
## The following sections can be repeated if you have more than one Sonarr, ##
|
||||
## Radarr, Lidarr, Readarr, Folder, Webhook, and/or Command Hook. ##
|
||||
## Identify each instance with a short key: [sonarr.uhd], [folder.tv]. ##
|
||||
## Changed in v1.0.0 (October 2026): map keys, not [[array]] list rows. ##
|
||||
## You MUST uncomment the [header.0], url and api_key for any Starr app. ##
|
||||
## Uncomment [sonarr.0], [radarr.0], [lidarr.0], and/or [readarr.0] plus the ##
|
||||
## url and api_key if that app is in use. ##
|
||||
## Do not set a bare UN_SONARR / UN_FOLDER / UN_WEBHOOK / UN_CMDHOOK. ##
|
||||
###############################################################################
|
||||
###############################################################################
|
||||
## ALL LINES BEGINNING WITH A HASH # ARE IGNORED COMMENTS ##
|
||||
## REMOVE THE HASH # FROM CONFIG LINES YOU WANT TO CHANGE ##
|
||||
###############################################################################
|
||||
###############################################################################
|
||||
|
||||
#[sonarr.0]
|
||||
## Empty uses the app name (Sonarr, Radarr, and so on). Set this for a
|
||||
## Sonarr-compatible app such as Sportarr so logs and Discord are not
|
||||
## tagged Sonarr. Add Whisparr as a Radarr instance with name = "Whisparr".
|
||||
## Hook exclude matches this name or the app dialect.
|
||||
## Names must be printable and cannot contain quotes, braces, or angle brackets.
|
||||
# name = ""
|
||||
# url = "http://127.0.0.1:8989"
|
||||
# api_key = "0123456789abcdef0123456789abcdef"
|
||||
## Username for HTTP basic authentication on the Starr app, if enabled.
|
||||
# http_user = ""
|
||||
## Password for HTTP basic authentication on the Starr app, if enabled.
|
||||
# http_pass = ""
|
||||
## Username for Starr native (forms) authentication, if enabled.
|
||||
# username = ""
|
||||
## Password for Starr native (forms) authentication, if enabled.
|
||||
# password = ""
|
||||
## List of paths where content is downloaded for this app.
|
||||
## Used as fallback if the path the Starr app reports does not exist or is not accessible.
|
||||
# paths = ['/downloads']
|
||||
## Protocols to process from the download queue. Starr apps historically
|
||||
## used "torrent" and "usenet" as the protocol strings, but newer versions
|
||||
## emit the full class names "TorrentDownloadProtocol" and
|
||||
## "UsenetDownloadProtocol" instead. List every string your app may return,
|
||||
## separated by commas. The default covers both the legacy and new torrent
|
||||
## strings. Add "usenet,UsenetDownloadProtocol" if you use a Usenet client.
|
||||
# protocols = "torrent,TorrentDownloadProtocol"
|
||||
## How long to wait for a reply from the backend.
|
||||
# timeout = "10s"
|
||||
## How long to wait after import before deleting the extracted items.
|
||||
# delete_delay = "5m"
|
||||
## If you use this app with NZB you may wish to delete archives after extraction.
|
||||
## General recommendation is: do not enable this for torrent use.
|
||||
## Setting this to true deletes the entire original download folder after import.
|
||||
# delete_orig = false
|
||||
## If you use Syncthing, setting this to true will make unpackerr wait for syncs to finish.
|
||||
# syncthing = false
|
||||
## When true, Unpackerr verifies the Starr app HTTPS certificate.
|
||||
## The default is false for backward compatibility.
|
||||
# valid_ssl = false
|
||||
## Per-archive uncompressed-byte cap. Empty uses the app default:
|
||||
## Sonarr 20GB, Radarr 75GB, Lidarr 4GB, Readarr 1GB.
|
||||
## `0` or `0B` disables the cap for this instance.
|
||||
# max_bytes = ""
|
||||
|
||||
#[radarr.0]
|
||||
## Empty uses the app name (Sonarr, Radarr, and so on). Set this for a
|
||||
## Sonarr-compatible app such as Sportarr so logs and Discord are not
|
||||
## tagged Sonarr. Add Whisparr as a Radarr instance with name = "Whisparr".
|
||||
## Hook exclude matches this name or the app dialect.
|
||||
## Names must be printable and cannot contain quotes, braces, or angle brackets.
|
||||
# name = ""
|
||||
# url = "http://127.0.0.1:7878"
|
||||
# api_key = "0123456789abcdef0123456789abcdef"
|
||||
## Username for HTTP basic authentication on the Starr app, if enabled.
|
||||
# http_user = ""
|
||||
## Password for HTTP basic authentication on the Starr app, if enabled.
|
||||
# http_pass = ""
|
||||
## Username for Starr native (forms) authentication, if enabled.
|
||||
# username = ""
|
||||
## Password for Starr native (forms) authentication, if enabled.
|
||||
# password = ""
|
||||
## List of paths where content is downloaded for this app.
|
||||
## Used as fallback if the path the Starr app reports does not exist or is not accessible.
|
||||
# paths = ['/downloads']
|
||||
## Protocols to process from the download queue. Starr apps historically
|
||||
## used "torrent" and "usenet" as the protocol strings, but newer versions
|
||||
## emit the full class names "TorrentDownloadProtocol" and
|
||||
## "UsenetDownloadProtocol" instead. List every string your app may return,
|
||||
## separated by commas. The default covers both the legacy and new torrent
|
||||
## strings. Add "usenet,UsenetDownloadProtocol" if you use a Usenet client.
|
||||
# protocols = "torrent,TorrentDownloadProtocol"
|
||||
## How long to wait for a reply from the backend.
|
||||
# timeout = "10s"
|
||||
## How long to wait after import before deleting the extracted items.
|
||||
# delete_delay = "5m"
|
||||
## If you use this app with NZB you may wish to delete archives after extraction.
|
||||
## General recommendation is: do not enable this for torrent use.
|
||||
## Setting this to true deletes the entire original download folder after import.
|
||||
# delete_orig = false
|
||||
## If you use Syncthing, setting this to true will make unpackerr wait for syncs to finish.
|
||||
# syncthing = false
|
||||
## When true, Unpackerr verifies the Starr app HTTPS certificate.
|
||||
## The default is false for backward compatibility.
|
||||
# valid_ssl = false
|
||||
## Per-archive uncompressed-byte cap. Empty uses the app default:
|
||||
## Sonarr 20GB, Radarr 75GB, Lidarr 4GB, Readarr 1GB.
|
||||
## `0` or `0B` disables the cap for this instance.
|
||||
# max_bytes = ""
|
||||
|
||||
#[lidarr.0]
|
||||
## Empty uses the app name (Sonarr, Radarr, and so on). Set this for a
|
||||
## Sonarr-compatible app such as Sportarr so logs and Discord are not
|
||||
## tagged Sonarr. Add Whisparr as a Radarr instance with name = "Whisparr".
|
||||
## Hook exclude matches this name or the app dialect.
|
||||
## Names must be printable and cannot contain quotes, braces, or angle brackets.
|
||||
# name = ""
|
||||
# url = "http://127.0.0.1:8686"
|
||||
# api_key = "0123456789abcdef0123456789abcdef"
|
||||
## Username for HTTP basic authentication on the Starr app, if enabled.
|
||||
# http_user = ""
|
||||
## Password for HTTP basic authentication on the Starr app, if enabled.
|
||||
# http_pass = ""
|
||||
## Username for Starr native (forms) authentication, if enabled.
|
||||
# username = ""
|
||||
## Password for Starr native (forms) authentication, if enabled.
|
||||
# password = ""
|
||||
## List of paths where content is downloaded for this app.
|
||||
## Used as fallback if the path the Starr app reports does not exist or is not accessible.
|
||||
# paths = ['/downloads']
|
||||
## Protocols to process from the download queue. Starr apps historically
|
||||
## used "torrent" and "usenet" as the protocol strings, but newer versions
|
||||
## emit the full class names "TorrentDownloadProtocol" and
|
||||
## "UsenetDownloadProtocol" instead. List every string your app may return,
|
||||
## separated by commas. The default covers both the legacy and new torrent
|
||||
## strings. Add "usenet,UsenetDownloadProtocol" if you use a Usenet client.
|
||||
# protocols = "torrent,TorrentDownloadProtocol"
|
||||
## How long to wait for a reply from the backend.
|
||||
# timeout = "10s"
|
||||
## How long to wait after import before deleting the extracted items.
|
||||
# delete_delay = "5m"
|
||||
## If you use this app with NZB you may wish to delete archives after extraction.
|
||||
## General recommendation is: do not enable this for torrent use.
|
||||
## Setting this to true deletes the entire original download folder after import.
|
||||
# delete_orig = false
|
||||
## If you use Syncthing, setting this to true will make unpackerr wait for syncs to finish.
|
||||
# syncthing = false
|
||||
## When true, Unpackerr verifies the Starr app HTTPS certificate.
|
||||
## The default is false for backward compatibility.
|
||||
# valid_ssl = false
|
||||
## Per-archive uncompressed-byte cap. Empty uses the app default:
|
||||
## Sonarr 20GB, Radarr 75GB, Lidarr 4GB, Readarr 1GB.
|
||||
## `0` or `0B` disables the cap for this instance.
|
||||
# max_bytes = ""
|
||||
## When enabled, FLAC and APE files with CUE sheets are split into
|
||||
## individual track files. ape_format and ape_compression choose what
|
||||
## an APE image becomes.
|
||||
# split_flac = false
|
||||
## ape keeps Monkey's Audio. wav and flac decode the audio.
|
||||
## Empty means ape. FLAC cannot store 32-bit or float PCM.
|
||||
## Compression is ignored unless the format is ape.
|
||||
# ape_format = "ape"
|
||||
## Used only when ape_format is ape (or empty). A default of 0 is set
|
||||
## to 2000. Accepted values are 1000, 2000, 3000, 4000, and 5000.
|
||||
## WAV and FLAC ignore this.
|
||||
# ape_compression = 0
|
||||
|
||||
#[readarr.0]
|
||||
## Empty uses the app name (Sonarr, Radarr, and so on). Set this for a
|
||||
## Sonarr-compatible app such as Sportarr so logs and Discord are not
|
||||
## tagged Sonarr. Add Whisparr as a Radarr instance with name = "Whisparr".
|
||||
## Hook exclude matches this name or the app dialect.
|
||||
## Names must be printable and cannot contain quotes, braces, or angle brackets.
|
||||
# name = ""
|
||||
# url = "http://127.0.0.1:8787"
|
||||
# api_key = "0123456789abcdef0123456789abcdef"
|
||||
## Username for HTTP basic authentication on the Starr app, if enabled.
|
||||
# http_user = ""
|
||||
## Password for HTTP basic authentication on the Starr app, if enabled.
|
||||
# http_pass = ""
|
||||
## Username for Starr native (forms) authentication, if enabled.
|
||||
# username = ""
|
||||
## Password for Starr native (forms) authentication, if enabled.
|
||||
# password = ""
|
||||
## List of paths where content is downloaded for this app.
|
||||
## Used as fallback if the path the Starr app reports does not exist or is not accessible.
|
||||
# paths = ['/downloads']
|
||||
## Protocols to process from the download queue. Starr apps historically
|
||||
## used "torrent" and "usenet" as the protocol strings, but newer versions
|
||||
## emit the full class names "TorrentDownloadProtocol" and
|
||||
## "UsenetDownloadProtocol" instead. List every string your app may return,
|
||||
## separated by commas. The default covers both the legacy and new torrent
|
||||
## strings. Add "usenet,UsenetDownloadProtocol" if you use a Usenet client.
|
||||
# protocols = "torrent,TorrentDownloadProtocol"
|
||||
## How long to wait for a reply from the backend.
|
||||
# timeout = "10s"
|
||||
## How long to wait after import before deleting the extracted items.
|
||||
# delete_delay = "5m"
|
||||
## If you use this app with NZB you may wish to delete archives after extraction.
|
||||
## General recommendation is: do not enable this for torrent use.
|
||||
## Setting this to true deletes the entire original download folder after import.
|
||||
# delete_orig = false
|
||||
## If you use Syncthing, setting this to true will make unpackerr wait for syncs to finish.
|
||||
# syncthing = false
|
||||
## When true, Unpackerr verifies the Starr app HTTPS certificate.
|
||||
## The default is false for backward compatibility.
|
||||
# valid_ssl = false
|
||||
## Per-archive uncompressed-byte cap. Empty uses the app default:
|
||||
## Sonarr 20GB, Radarr 75GB, Lidarr 4GB, Readarr 1GB.
|
||||
## `0` or `0B` disables the cap for this instance.
|
||||
# max_bytes = ""
|
||||
|
||||
##################################################################################
|
||||
### ### STOP HERE ### STOP HERE ### STOP HERE ### STOP HERE #### STOP HERE ### #
|
||||
### Only using Starr apps? The things above. The below configs are OPTIONAL. ### #
|
||||
##################################################################################
|
||||
|
||||
|
||||
##-Folders-#######################################################################
|
||||
## This application can also watch folders for things to extract. If you copy a ##
|
||||
## subfolder into a watched folder (defined below) any extractable items in the ##
|
||||
## folder will be decompressed. This has nothing to do with Starr applications. ##
|
||||
##################################################################################
|
||||
#[folder.0]
|
||||
# path = '/downloads/auto_extract'
|
||||
## How often this folder is scanned for new archives.
|
||||
## Off (`0s`) uses filesystem events (inotify and friends).
|
||||
## Enable a poll interval if files never show up in the queue, which is
|
||||
## common on Docker and CIFS. Polling increases disk reads and is not
|
||||
## recommended when fsnotify works.
|
||||
# interval = "0s"
|
||||
## Paths to ignore while watching this folder. Excluded paths and their
|
||||
## children are not tracked or extracted.
|
||||
## Paths may be absolute, or relative to this folder's `path`.
|
||||
# exclude_paths = []
|
||||
## Path to extract files to. The default (leaving this blank) is the same as `path` (above).
|
||||
# extract_path = ''
|
||||
## Delete extracted or original files this long after extraction.
|
||||
## The default is 10m. Set to 0 to disable all deletes. Deletes also
|
||||
## require `delete_files` and/or `delete_original`. Uses Go Duration.
|
||||
# delete_after = "10m"
|
||||
## Nested archives are extracted by default. Disable recursion to extract only the outer archive.
|
||||
# disable_recursion = false
|
||||
## Per-archive uncompressed-byte cap. Empty is unlimited. `0` or `0B` is also unlimited.
|
||||
# max_bytes = ""
|
||||
## Cap on files, directories, and symlinks created during extract. `0` is unlimited.
|
||||
# max_files = 0
|
||||
## Cap on `bytes written / archive file size`. `0` is unlimited.
|
||||
# max_ratio = 0
|
||||
## Cap on archives extracted from this folder's output (one extras pass). `0` is unlimited.
|
||||
# max_nested = 0
|
||||
## Walk depth for the extras pass. `0` is unlimited.
|
||||
# extras_max_depth = 0
|
||||
## When true, the initial archive search may include symlink-named files.
|
||||
## The extras pass never follows archive-member zip links.
|
||||
# allow_symlinks = false
|
||||
## Delete extracted files after successful extraction? delete_after must be greater than 0.
|
||||
# delete_files = false
|
||||
## Delete original items after successful extraction? delete_after must be greater than 0.
|
||||
# delete_original = false
|
||||
## Disable extraction log (unpackerred.txt) file creation?
|
||||
# disable_log = false
|
||||
## Move extracted files into original folder? If false, files go into an _unpackerred folder.
|
||||
# move_back = false
|
||||
## Set this to true if you want this app to extract ISO files with .iso extension.
|
||||
# extract_isos = false
|
||||
## When true, an execute bit stored in the archive is kept for owner,
|
||||
## group, or other only when file_mode already grants that class access.
|
||||
## A 6 beside an archived 7 becomes 7, a 4 becomes 5, and a 0 stays 0.
|
||||
## Off, extracted files use file_mode only. Starr extracts stay off.
|
||||
## The process umask still applies.
|
||||
# preserve_exec = false
|
||||
## Incomplete-download suffixes such as `.part` or `.crdownload`. Extraction
|
||||
## stays in waiting as long as any matching file is in the watched item's
|
||||
## top folder. Subfolders are not scanned. The UI and config always store
|
||||
## a leading period. Waiting items are rechecked every 5s even without the
|
||||
## poller. Enabling folder poller will double-poll the folder and increase
|
||||
## disk reads. Useful when fsnotify is not available, and not recommended
|
||||
## when it is.
|
||||
# wait_extensions = []
|
||||
## After start delay, skip watched folders that have no extractable archives.
|
||||
## They leave waiting without history or webhooks.
|
||||
# skip_empty = false
|
||||
|
||||
####################
|
||||
### Hook Payload ###
|
||||
####################
|
||||
# Extra IDs and custom event titles shared by every webhook and command hook.
|
||||
[hooks]
|
||||
## Nested [hooks.custom_ids] table, not an inline map.
|
||||
## Webhooks get `customIDs` in JSON (`{{index .CustomIDs "url"}}` in templates).
|
||||
## Command hooks get `UN_CUSTOM_ID_<key>` environment variables.
|
||||
## Env: UN_HOOKS_CUSTOM_IDS_url=https://…
|
||||
## [hooks.custom_ids]
|
||||
## url = "https://unpackerr.example"
|
||||
## Nested [hooks.titles] table with one field per extract event:
|
||||
## waiting, queued, extracting, extractfailed, extracted, imported,
|
||||
## deleting, deletefailed, deleted, extractednothing.
|
||||
## Empty values keep the built-in English title. Built-in Discord, Slack,
|
||||
## Telegram, Gotify, and Pushover templates use this string.
|
||||
## Env: UN_HOOKS_TITLES_EXTRACTING=Archive Found
|
||||
## [hooks.titles]
|
||||
## extracting = "Archive Found"
|
||||
|
||||
################
|
||||
### Webhooks ###
|
||||
################
|
||||
# Sends a webhook when an extraction queues, starts, finishes, and/or is deleted.
|
||||
# Created to integrate with notifiarr.com.
|
||||
# Also works natively with Discord.com, Telegram.org, Slack.com, ntfy, Apprise, and Mattermost webhooks.
|
||||
# Can possibly be used with other services by providing a custom template_path.
|
||||
###### Don't forget to uncomment [webhook.0] and url at a minimum !!!!
|
||||
#[webhook.0]
|
||||
## Notifiarr: https://notifiarr.com/api/v1/notification/unpackerr plus headers.X-Api-Key.
|
||||
## A path API key in a pasted URL is moved to that header.
|
||||
# url = "https://notifiarr.com/api/v1/notification/unpackerr"
|
||||
## Provide an optional name to hide the URL in logs.
|
||||
## If a name is not provided then the URL is used.
|
||||
# name = ""
|
||||
## Do not log success (less log spam).
|
||||
# silent = false
|
||||
## List of event ids to send notification for, [0] for all.
|
||||
## The default is [0] and this is an example:
|
||||
# events = [1, 4, 6]
|
||||
## ===> Advanced Optional Webhook Configuration <===
|
||||
## Discord, Slack, and Mattermost: bot/display name. Telegram: chat_id.
|
||||
## Gotify, ntfy, and Apprise: optional title prefix. Unused by Notifiarr JSON.
|
||||
# nickname = "Unpackerr"
|
||||
## Also passed into templates. Slack and Mattermost optional channel. Pushover user key.
|
||||
# channel = ""
|
||||
## Passed into webhook templates. Pushover requires the application token. ntfy sends Authorization Bearer when set. Unused by Discord, Telegram, and Slack incoming webhooks.
|
||||
# token = ""
|
||||
## Dialect tokens skip every instance of that API. Any other value matches an instance name. None by default. This is an example:
|
||||
# exclude = ["readarr", "lidarr"]
|
||||
## Used when template is empty (automatic). A named template ignores this file.
|
||||
# template_path = ''
|
||||
## Override automatic template detection. Values: notifiarr, discord, telegram, gotify, pushover, slack, ntfy, apprise, mattermost. Empty uses the URL or template_path.
|
||||
# template = ""
|
||||
## Default on for Discord webhooks and Telegram sendMessage. Other templates ignore this. Set false to post a new message for every extract event.
|
||||
# update = true
|
||||
## Set this to true to ignore the SSL certificate on the server.
|
||||
# ignore_ssl = false
|
||||
## You can adjust how long to wait for a server response.
|
||||
# timeout = "10s"
|
||||
## If your custom template uses another MIME type, set this.
|
||||
# content_type = "application/json"
|
||||
## Nested [webhook.0.headers] table. Header names are letters, digits, underscore, or hyphen.
|
||||
## Applied first; Content-Type and ntfy Authorization Bearer from token still win.
|
||||
## Notifiarr client key: X-Api-Key. Env: UN_WEBHOOK_0_HEADERS_X-Api-Key=api_key_from_notifiarr_com
|
||||
## [webhook.0.headers]
|
||||
## X-Api-Key = "api_key_from_notifiarr_com"
|
||||
|
||||
#####################
|
||||
### Command Hooks ###
|
||||
#####################
|
||||
# Executes a script or command when an extraction queues, starts, finishes, and/or is deleted.
|
||||
# All data is passed in as environment variables. Try /usr/bin/env to see what variables are available.
|
||||
###### Don't forget to uncomment [cmdhook.0] at a minimum !!!!
|
||||
#[cmdhook.0]
|
||||
# command = '/downloads/scripts/command.sh'
|
||||
## Provide an optional name to hide the URL in logs.
|
||||
## If a name is not provided the first word in the command is used.
|
||||
# name = ""
|
||||
## Runs the command inside /bin/sh ('nix) or cmd.exe (Windows).
|
||||
# shell = false
|
||||
## Do not log command's output.
|
||||
# silent = false
|
||||
## List of event ids to run command for, [0] for all.
|
||||
## The default is [0] and this is an example:
|
||||
# events = [1, 4, 7]
|
||||
## ===> Optional Command Hook Configuration <===
|
||||
## Dialect tokens skip every instance of that API. Any other value matches an instance name. None by default. This is an example:
|
||||
# exclude = ["readarr", "lidarr"]
|
||||
## You can adjust how long to wait for the command to run.
|
||||
# timeout = "10s"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
modules:
|
||||
http_2xx:
|
||||
prober: http
|
||||
timeout: 5s
|
||||
http:
|
||||
valid_status_codes: []
|
||||
method: GET
|
||||
preferred_ip_protocol: ip4
|
||||
@@ -8,14 +8,15 @@ services:
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus-data:/prometheus
|
||||
|
||||
networks:
|
||||
- default
|
||||
- shared-prometheus-services
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: grafana
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: lucas
|
||||
GF_SECURITY_ADMIN_PASSWORD: Mira2012
|
||||
@@ -47,9 +48,9 @@ services:
|
||||
volumes:
|
||||
- '/:/host:ro,rslave'
|
||||
- ./node_exporter/textfile_collector:/textfile:ro
|
||||
ports:
|
||||
- '9100:9100'
|
||||
|
||||
# ports:
|
||||
# - '9100:9100'
|
||||
network_mode: host
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:latest
|
||||
container_name: cadvisor
|
||||
@@ -58,13 +59,135 @@ services:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /data:/data:ro
|
||||
- /docker:/docker:ro
|
||||
# - /data:/data:ro
|
||||
# - /docker:/docker:ro
|
||||
- /var/run:/var/run:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker:/var/lib/docker:ro
|
||||
- /dev/disk:/dev/disk:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
command:
|
||||
- "--disable_metrics=percpu,tcp,udp,process,sched"
|
||||
- "--housekeeping_interval=30s"
|
||||
- "--housekeeping_interval=1m"
|
||||
- "--docker_only=true"
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
blackbox-exporter:
|
||||
image: prom/blackbox-exporter:latest
|
||||
container_name: blackbox-exporter
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9115:9115"
|
||||
volumes:
|
||||
- ./blackbox.yml:/etc/blackbox_exporter/config.yml:ro
|
||||
sonarr-de-exporter:
|
||||
image: ghcr.io/onedr0p/exportarr:latest
|
||||
container_name: sonarr-de-exporter
|
||||
environment:
|
||||
PORT: 9707
|
||||
URL: http://sonarr-de:8989
|
||||
APIKEY: 707d4fcbb3d74822ba990c6a8b7278e9
|
||||
command: ["sonarr"]
|
||||
networks:
|
||||
- default
|
||||
- mediarr-services
|
||||
|
||||
radarr-de-exporter:
|
||||
image: ghcr.io/onedr0p/exportarr:latest
|
||||
container_name: radarr-de-exporter
|
||||
environment:
|
||||
PORT: 9711
|
||||
URL: http://radarr-de:7878
|
||||
APIKEY: 29f67f0ac8894357b22ea3f7fc1abfdf
|
||||
command: ["radarr"]
|
||||
networks:
|
||||
- default
|
||||
- mediarr-services
|
||||
|
||||
radarr-en-exporter:
|
||||
image: ghcr.io/onedr0p/exportarr:latest
|
||||
container_name: radarr-en-exporter
|
||||
environment:
|
||||
PORT: 9708
|
||||
URL: http://radarr-en:7878
|
||||
APIKEY: 4332aee075c748f2acee41aa26f06007
|
||||
command: ["radarr"]
|
||||
networks:
|
||||
- default
|
||||
- mediarr-services
|
||||
|
||||
lidarr-exporter:
|
||||
image: ghcr.io/onedr0p/exportarr:latest
|
||||
container_name: lidarr-exporter
|
||||
environment:
|
||||
PORT: 9709
|
||||
URL: http://lidarr:8686
|
||||
APIKEY: bb41cbf2b17e439290f90ecfaeb24dff
|
||||
command: ["lidarr"]
|
||||
networks:
|
||||
- default
|
||||
- mediarr-services
|
||||
|
||||
prowlarr-exporter:
|
||||
image: ghcr.io/onedr0p/exportarr:latest
|
||||
container_name: prowlarr-exporter
|
||||
environment:
|
||||
PORT: 9710
|
||||
URL: http://prowlarr:9696
|
||||
APIKEY: 317f5dd6c06446c6a352a621be607acb
|
||||
command: ["prowlarr"]
|
||||
networks:
|
||||
- default
|
||||
- mediarr-services
|
||||
|
||||
#listenarr-exporter:
|
||||
# build: listenarr-exporter
|
||||
# container_name: listenarr-exporter
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# LISTENARR_URL: http://listenarr:4545
|
||||
# LISTENARR_APIKEY: FtKkg30FLbORoaJXXBx4oHJL2r1/3kPtZXKlxqSloWs
|
||||
# ports:
|
||||
# - "9712:9712"
|
||||
# networks:
|
||||
# - default
|
||||
# - mediarr-services
|
||||
|
||||
loki:
|
||||
image: grafana/loki:3.0.0
|
||||
container_name: loki
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3100:3100"
|
||||
command: -config.file=/etc/loki/local-config.yaml
|
||||
|
||||
alloy:
|
||||
image: grafana/alloy:latest
|
||||
container_name: alloy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "12345:12345"
|
||||
command: >
|
||||
run /etc/alloy/config.alloy
|
||||
--server.http.listen-addr=0.0.0.0:12345
|
||||
volumes:
|
||||
- ./alloy/config.alloy:/etc/alloy/config.alloy:ro
|
||||
- /var/log:/var/log:ro
|
||||
- /var/log/journal:/var/log/journal:ro
|
||||
- /run/log/journal:/run/log/journal:ro
|
||||
- /etc/machine-id:/etc/machine-id:ro
|
||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||
|
||||
volumes:
|
||||
prometheus-data:
|
||||
grafana-data:
|
||||
networks:
|
||||
default:
|
||||
mediarr-services:
|
||||
external: true
|
||||
shared-proxy-services:
|
||||
external: true
|
||||
shared-prometheus-services:
|
||||
external: true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
scrape_interval: 30s
|
||||
evaluation_interval: 30s
|
||||
|
||||
scrape_configs:
|
||||
|
||||
@@ -8,6 +8,31 @@ scrape_configs:
|
||||
static_configs:
|
||||
- targets:
|
||||
- "prometheus:9090"
|
||||
- job_name: blackbox_https
|
||||
metrics_path: /probe
|
||||
params:
|
||||
module: [http_2xx]
|
||||
|
||||
static_configs:
|
||||
- targets:
|
||||
- https://cloud.someoneslinux.selfhost.eu
|
||||
- https://media.someoneslinux.selfhost.eu
|
||||
- https://audiobooks.someoneslinux.selfhost.eu
|
||||
# - https://login.someoneslinux.selfhost.eu
|
||||
- https://auto.someoneslinux.selfhost.eu
|
||||
- https://music.someoneslinux.selfhost.eu
|
||||
- https://passwd.someoneslinux.selfhost.eu
|
||||
- https://requests.someoneslinux.selfhost.eu
|
||||
# - https://torrent.someoneslinux.selfhost.eu
|
||||
- https://status.someoneslinux.selfhost.eu
|
||||
|
||||
relabel_configs:
|
||||
- source_labels: [__address__]
|
||||
target_label: __param_target
|
||||
- source_labels: [__param_target]
|
||||
target_label: instance
|
||||
- target_label: __address__
|
||||
replacement: blackbox-exporter:9115
|
||||
|
||||
- job_name: "node-exporter"
|
||||
static_configs:
|
||||
@@ -16,22 +41,71 @@ scrape_configs:
|
||||
labels:
|
||||
host: serverhost
|
||||
- targets:
|
||||
- "node-exporter:9100"
|
||||
- "192.168.0.4:9100"
|
||||
labels:
|
||||
host: "mediahost"
|
||||
|
||||
- targets:
|
||||
- "192.168.0.39:9100"
|
||||
labels:
|
||||
host: "quiethost"
|
||||
- job_name: "cadvisor"
|
||||
static_configs:
|
||||
- targets:
|
||||
- "cadvisor:8080"
|
||||
labels:
|
||||
host: mediahost
|
||||
- targets:
|
||||
- "192.168.0.2:8081"
|
||||
labels:
|
||||
host: serverhost
|
||||
- job_name: "smartctl"
|
||||
static_configs:
|
||||
- targets:
|
||||
- "192.168.0.2:9633"
|
||||
labels:
|
||||
host: serverhost
|
||||
- targets:
|
||||
- "192.168.0.39:9633"
|
||||
labels:
|
||||
host: "quiethost"
|
||||
- targets:
|
||||
- "192.168.0.4:9633"
|
||||
labels:
|
||||
host: "mediahost"
|
||||
- job_name: 'sonarr-de'
|
||||
static_configs:
|
||||
- targets: ['sonarr-de-exporter:9707']
|
||||
|
||||
- job_name: 'radarr-de'
|
||||
static_configs:
|
||||
- targets: ['radarr-de-exporter:9711']
|
||||
|
||||
- job_name: 'radarr-en'
|
||||
static_configs:
|
||||
- targets: ['radarr-en-exporter:9708']
|
||||
|
||||
- job_name: 'lidarr'
|
||||
static_configs:
|
||||
- targets: ['lidarr-exporter:9709']
|
||||
|
||||
- job_name: 'prowlarr'
|
||||
static_configs:
|
||||
- targets: ['prowlarr-exporter:9710']
|
||||
- job_name: "listenarr"
|
||||
static_configs:
|
||||
- targets:
|
||||
- listenarr-exporter:9712
|
||||
|
||||
- job_name: diun
|
||||
metrics_path: /metrics
|
||||
authorization:
|
||||
credentials: "Qj#8C!@Uz3uRWm@mCd@KH6Bo7nfQ4K"
|
||||
static_configs:
|
||||
- targets:
|
||||
- diun:9090
|
||||
labels:
|
||||
host: "mediahost"
|
||||
- targets:
|
||||
- "192.168.0.2:9090"
|
||||
labels:
|
||||
host: "mediahost"
|
||||
@@ -23,6 +23,7 @@ services:
|
||||
- /data/navidrome/music/kamerun:/kamerun:ro
|
||||
- /data/navidrome/music/weihnachten:/weihnachten:ro
|
||||
- /data/navidrome/music/playlists:/playlists:ro
|
||||
- /data/navidrome/music/playlists-export:/playlists-export
|
||||
networks:
|
||||
- shared-proxy-services
|
||||
- default
|
||||
|
||||
@@ -27,7 +27,8 @@ services:
|
||||
- OVERWRITEPROTOCOL=https
|
||||
- OVERWRITECLIURL=https://${NEXTCLOUD_DOMAIN}
|
||||
- OVERWRITEHOST=${NEXTCLOUD_DOMAIN}
|
||||
- TRUSTED_PROXIES=nginx 172.16.0.0/12
|
||||
- TRUSTED_PROXIES=172.16.0.0/12
|
||||
- DEFAULT_PHONE_REGION=AT
|
||||
# SMTP
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
|
||||
@@ -16,8 +16,8 @@ server {
|
||||
music.someoneslinux.selfhost.eu
|
||||
passwd.someoneslinux.selfhost.eu
|
||||
requests.someoneslinux.selfhost.eu
|
||||
torrent.someoneslinux.selfhost.eu
|
||||
status.someoneslinux.selfhost.eu;
|
||||
status.someoneslinux.selfhost.eu
|
||||
code.someoneslinux.selfhost.eu;
|
||||
#ssl_conf_command Groups X25519:secp256r1:secp384r1;
|
||||
# ssl_conf_command Groups X25519:prime256v1:secp384r1;
|
||||
location /.well-known/acme-challenge/ {
|
||||
@@ -27,12 +27,28 @@ server {
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
|
||||
}
|
||||
|
||||
listen 8080;
|
||||
location /nginx_status {
|
||||
stub_status;
|
||||
allow 172.16.0.0/12;
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
|
||||
# Common SSL settings
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/live/cloud.someoneslinux.selfhost.eu/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/live/cloud.someoneslinux.selfhost.eu/privkey.pem;
|
||||
return 404;
|
||||
}
|
||||
|
||||
###################
|
||||
# NEXTCLOUD
|
||||
@@ -51,6 +67,8 @@ server {
|
||||
|
||||
location / {
|
||||
proxy_pass http://nextcloud:80;
|
||||
# Do not try another upstream on errors
|
||||
proxy_next_upstream off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -76,6 +94,8 @@ server {
|
||||
|
||||
location / {
|
||||
proxy_pass http://jellyfin:8096;
|
||||
# Do not try another upstream on errors
|
||||
proxy_next_upstream off;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
@@ -103,6 +123,8 @@ server {
|
||||
|
||||
location / {
|
||||
proxy_pass http://audiobookshelf:80;
|
||||
# Do not try another upstream on errors
|
||||
proxy_next_upstream off;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
@@ -112,40 +134,14 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
###################
|
||||
# AUTHENTIK
|
||||
###################
|
||||
server {
|
||||
listen 443 ssl;http2 on;
|
||||
server_name login.someoneslinux.selfhost.eu;
|
||||
|
||||
add_header Content-Security-Policy "frame-ancestors 'self';" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/live/cloud.someoneslinux.selfhost.eu/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/live/cloud.someoneslinux.selfhost.eu/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://authentik-server-1:9000;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
|
||||
####################
|
||||
## HOME ASSISTANT
|
||||
## AUTHENTIK
|
||||
####################
|
||||
#server {
|
||||
# listen 443 ssl;http2 on;
|
||||
# server_name auto.someoneslinux.selfhost.eu;
|
||||
# server_name login.someoneslinux.selfhost.eu;
|
||||
#
|
||||
# add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'self';" always;
|
||||
# add_header Content-Security-Policy "frame-ancestors 'self';" always;
|
||||
# add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
# add_header X-Content-Type-Options "nosniff" always;
|
||||
# add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
@@ -155,17 +151,45 @@ server {
|
||||
# ssl_certificate_key /etc/nginx/certs/live/cloud.someoneslinux.selfhost.eu/privkey.pem;
|
||||
#
|
||||
# location / {
|
||||
# proxy_pass http://192.168.0.4:8123;
|
||||
# proxy_pass http://authentik-server-1:9000;
|
||||
# proxy_http_version 1.1;
|
||||
#
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header Upgrade $http_upgrade;
|
||||
# proxy_set_header Connection $connection_upgrade;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto https;
|
||||
# proxy_set_header X-Forwarded-Host $host;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# }
|
||||
#}
|
||||
|
||||
###################
|
||||
# HOME ASSISTANT
|
||||
###################
|
||||
server {
|
||||
listen 443 ssl;http2 on;
|
||||
server_name auto.someoneslinux.selfhost.eu;
|
||||
|
||||
# add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'self';" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/live/cloud.someoneslinux.selfhost.eu/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/live/cloud.someoneslinux.selfhost.eu/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://192.168.0.4:8123;
|
||||
# Do not try another upstream on errors
|
||||
proxy_next_upstream off;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
}
|
||||
|
||||
###################
|
||||
# NAVIDROME
|
||||
###################
|
||||
@@ -184,6 +208,8 @@ server {
|
||||
location / {
|
||||
proxy_pass http://navidrome:4533;
|
||||
|
||||
# Do not try another upstream on errors
|
||||
proxy_next_upstream off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -208,6 +234,8 @@ server {
|
||||
|
||||
location / {
|
||||
proxy_pass http://vaultwarden:80;
|
||||
# Do not try another upstream on errors
|
||||
proxy_next_upstream off;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
@@ -216,6 +244,8 @@ server {
|
||||
|
||||
location /notifications/hub {
|
||||
proxy_pass http://vaultwarden:3012;
|
||||
# Do not try another upstream on errors
|
||||
proxy_next_upstream off;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
@@ -240,6 +270,8 @@ server {
|
||||
|
||||
location / {
|
||||
proxy_pass http://seerr:5055;
|
||||
# Do not try another upstream on errors
|
||||
proxy_next_upstream off;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
@@ -249,33 +281,36 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
###################
|
||||
# QBITTORRENT
|
||||
###################
|
||||
server {
|
||||
listen 443 ssl;http2 on;
|
||||
server_name torrent.someoneslinux.selfhost.eu;
|
||||
|
||||
add_header Content-Security-Policy "frame-ancestors 'self';" always;
|
||||
# add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'self';" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/live/cloud.someoneslinux.selfhost.eu/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/live/cloud.someoneslinux.selfhost.eu/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://192.168.0.2:8080;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
}
|
||||
####################
|
||||
## QBITTORRENT
|
||||
####################
|
||||
#server {
|
||||
# listen 443 ssl;http2 on;
|
||||
# server_name torrent.someoneslinux.selfhost.eu;
|
||||
#
|
||||
# add_header Content-Security-Policy "frame-ancestors 'self';" always;
|
||||
## add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'self';" always;
|
||||
# add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
# add_header X-Content-Type-Options "nosniff" always;
|
||||
# add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
#
|
||||
# ssl_certificate /etc/nginx/certs/live/cloud.someoneslinux.selfhost.eu/fullchain.pem;
|
||||
# ssl_certificate_key /etc/nginx/certs/live/cloud.someoneslinux.selfhost.eu/privkey.pem;
|
||||
#
|
||||
# location / {
|
||||
# proxy_pass http://192.168.0.2:8080;
|
||||
# # Do not try another upstream on errors
|
||||
# proxy_next_upstream off;
|
||||
# proxy_http_version 1.1;
|
||||
#
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header Upgrade $http_upgrade;
|
||||
# proxy_set_header Connection $connection_upgrade;
|
||||
# proxy_set_header X-Forwarded-Proto https;
|
||||
# }
|
||||
#
|
||||
#}
|
||||
|
||||
###################
|
||||
# GRFANA
|
||||
@@ -294,6 +329,8 @@ server {
|
||||
|
||||
location / {
|
||||
proxy_pass http://grafana:3000;
|
||||
# Do not try another upstream on errors
|
||||
proxy_next_upstream off;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
@@ -302,3 +339,34 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
}
|
||||
|
||||
###################
|
||||
# GITEA
|
||||
###################
|
||||
server {
|
||||
listen 443 ssl;http2 on;
|
||||
server_name code.someoneslinux.selfhost.eu;
|
||||
|
||||
add_header Content-Security-Policy "frame-ancestors 'self';" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/live/cloud.someoneslinux.selfhost.eu/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/live/cloud.someoneslinux.selfhost.eu/privkey.pem;
|
||||
|
||||
client_max_body_size 10G;
|
||||
location / {
|
||||
proxy_pass http://gitea:3000;
|
||||
# Do not try another upstream on errors
|
||||
proxy_next_upstream off;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,9 @@ services:
|
||||
image: nginx:stable
|
||||
container_name: nginx
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
volumes:
|
||||
- ./conf.d:/etc/nginx/conf.d
|
||||
- ./certs:/etc/nginx/certs
|
||||
@@ -15,6 +13,21 @@ services:
|
||||
# Access and error logs
|
||||
- ./logs:/var/log/nginx
|
||||
networks:
|
||||
- default
|
||||
- shared-proxy-services
|
||||
|
||||
nginx-exporter:
|
||||
image: nginx/nginx-prometheus-exporter:latest
|
||||
container_name: nginx-exporter
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- --nginx.scrape-uri=http://nginx:8080/nginx_status
|
||||
ports:
|
||||
- "9113:9113"
|
||||
depends_on:
|
||||
- nginx
|
||||
networks:
|
||||
- default
|
||||
- shared-proxy-services
|
||||
|
||||
crowdsec:
|
||||
@@ -29,7 +42,8 @@ services:
|
||||
- crowdsec-db:/var/lib/crowdsec/data
|
||||
- ./logs:/var/log/nginx:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
|
||||
networks:
|
||||
- default
|
||||
|
||||
certbot:
|
||||
image: certbot/certbot:latest
|
||||
@@ -41,7 +55,9 @@ services:
|
||||
- shared-proxy-services
|
||||
|
||||
networks:
|
||||
default:
|
||||
shared-proxy-services:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
crowdsec-db:
|
||||
@@ -3,7 +3,7 @@ services:
|
||||
image: redis:7
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --requirepass PerCent7heStorySp0tInOrd3r
|
||||
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
# Note: Do not edit this file directly.
|
||||
# Your changes will be overwritten!
|
||||
|
||||
# Server
|
||||
[Interface]
|
||||
PrivateKey = 6CXCZMoH8bj7Ku//g0vyXms2IIffHR18K8RnOklzgnc=
|
||||
Address = 10.8.0.1/24
|
||||
ListenPort = 51820
|
||||
PreUp =
|
||||
PostUp = iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE; iptables -A INPUT -p udp -m udp --dport 51820 -j ACCEPT; iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT;
|
||||
PreDown =
|
||||
PostDown = iptables -t nat -D POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE; iptables -D INPUT -p udp -m udp --dport 51820 -j ACCEPT; iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT;
|
||||
+23
-3
@@ -2,7 +2,13 @@
|
||||
|
||||
OUT=/docker/monitoring/node_exporter/textfile_collector/custom.prom
|
||||
TS=$(date +%s%3N)
|
||||
DOCKER_CONTAINER_NUM=44
|
||||
if [[ "$(hostname)" == "mediahost" ]]; then
|
||||
DOCKER_CONTAINER_NUM=47
|
||||
elif [[ "$(hostname)" == "serverhost" ]]; then
|
||||
DOCKER_CONTAINER_NUM=4
|
||||
elif [[ "$(hostname)" == "quiethost" ]]; then
|
||||
DOCKER_CONTAINER_NUM=1
|
||||
fi
|
||||
{
|
||||
# Docker container states
|
||||
docker ps -a --format '{{.Names}}' | while read -r name; do
|
||||
@@ -15,9 +21,23 @@ DOCKER_CONTAINER_NUM=44
|
||||
|
||||
echo docker_container_number $DOCKER_CONTAINER_NUM
|
||||
|
||||
docker ps -a --format '{{.Names}}' | grep -E '^qbittorrent' | while read -r qbit_host; do
|
||||
IP_JSON=$(docker exec "$qbit_host" curl -fsSL http://ip-api.com/json) || continue
|
||||
|
||||
IP=$(jq -r '.query' <<<"$IP_JSON")
|
||||
COUNTRY=$(jq -r '.country' <<<"$IP_JSON")
|
||||
CITY=$(jq -r '.city' <<<"$IP_JSON")
|
||||
ISP=$(jq -r '.isp' <<<"$IP_JSON" | sed 's/"/\\"/g')
|
||||
COUNTRYCODE=$(jq -r '.countryCode' <<<"$IP_JSON")
|
||||
LAT=$(jq -r '.lat' <<<"$IP_JSON")
|
||||
LON=$(jq -r '.lon' <<<"$IP_JSON")
|
||||
|
||||
echo "public_ip_info{ip=\"$IP\",country=\"$COUNTRY\",city=\"$CITY\",isp=\"$ISP\",countrycode=\"$COUNTRYCODE\",lat=\"$LAT\",lon=\"$LON\",exported_host=\"$qbit_host\"} 1"
|
||||
done
|
||||
|
||||
|
||||
# Public IP information
|
||||
IP_JSON=$(curl -fsSL http://ip-api.com/json)
|
||||
|
||||
IP=$(jq -r '.query' <<< "$IP_JSON")
|
||||
COUNTRY=$(jq -r '.country' <<< "$IP_JSON")
|
||||
CITY=$(jq -r '.city' <<< "$IP_JSON")
|
||||
@@ -26,7 +46,7 @@ DOCKER_CONTAINER_NUM=44
|
||||
LAT=$(jq -r '.lat' <<< "$IP_JSON")
|
||||
LON=$(jq -r '.lon' <<< "$IP_JSON")
|
||||
|
||||
echo "public_ip_info{ip=\"$IP\",country=\"$COUNTRY\",city=\"$CITY\",isp=\"$ISP\",countrycode=\"$COUNTRYCODE\",lat=\"$LAT\",lon=\"$LON\"} 1"
|
||||
echo "public_ip_info{ip=\"$IP\",country=\"$COUNTRY\",city=\"$CITY\",isp=\"$ISP\",countrycode=\"$COUNTRYCODE\",lat=\"$LAT\",lon=\"$LON\",exported_host=\"$(hostname)\"} 1"
|
||||
echo "custom_metrics_last_update_timestamp_seconds $(date +%s)"
|
||||
} > "$OUT.$$"
|
||||
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
OUT="/docker/monitoring/node_exporter/textfile_collector/smart_selftest.prom"
|
||||
|
||||
> "$OUT"
|
||||
|
||||
for dev in $(smartctl --scan-open | awk '{print $1}'); do
|
||||
name=$(basename "$dev")
|
||||
LAST=0
|
||||
if [[ "$dev" == *nvme* ]]; then
|
||||
POWER_ON=$(smartctl -a "$dev" | awk -F': *' '/Power On Hours:/ {print $2}')
|
||||
LAST=$(smartctl -l selftest "$dev" 2>/dev/null \
|
||||
| grep "Completed" \
|
||||
| head -1 \
|
||||
| awk '{print $(NF-5)}')
|
||||
elif [[ "$dev" == *sd* ]]; then
|
||||
POWER_ON=$(smartctl -a "$dev" | awk '/Power_On_Hours/ {print $10}')
|
||||
LAST=$(smartctl -l selftest "$dev" 2>/dev/null \
|
||||
| grep "Completed" \
|
||||
| head -1 \
|
||||
| awk '{print $(NF-1)}')
|
||||
MODEL=$(smartctl -i "$dev" | awk -F': *' '/Device Model/ {print $2}')
|
||||
# ST2000LM003 self-test hour counter rolls over at 65536
|
||||
if [[ "$MODEL" == "ST2000LM003 HN-M201RAD" ]] && \
|
||||
[[ "$LAST" =~ ^[0-9]+$ ]] && \
|
||||
[[ "$POWER_ON" =~ ^[0-9]+$ ]] && \
|
||||
(( POWER_ON > 65535 && LAST < 65536 )); then
|
||||
LAST=$((LAST + 65536))
|
||||
fi
|
||||
else
|
||||
POWER_ON=99999999999
|
||||
echo $dev
|
||||
fi
|
||||
|
||||
# Skip devices without self-test history
|
||||
echo $name: Last: ${LAST}, PowerOn: ${POWER_ON}
|
||||
echo "smart_last_selftest_poweron_hours{device=\"$name\"} $LAST" >> "$OUT"
|
||||
|
||||
done
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
*/5 * * * * /home/lucas/scripts/docker_compose/node-exporter-pubip-docker-states.sh
|
||||
@@ -10,6 +10,19 @@ services:
|
||||
volumes:
|
||||
- '/:/host:ro,rslave'
|
||||
- ./node_exporter/textfile_collector:/textfile:ro
|
||||
# ports:
|
||||
# - '9100:9100'
|
||||
network_mode: host
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:latest
|
||||
container_name: cadvisor
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '9100:9100'
|
||||
|
||||
- "8081:8080"
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /data:/data:ro
|
||||
- /docker:/docker:ro
|
||||
- /var/run:/var/run:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker:/var/lib/docker:ro
|
||||
@@ -7,18 +7,18 @@ cp -f /hostscripts/torrent_qbittorrent_complete.sh /config/
|
||||
|
||||
chmod +x /config/torrent_qbittorrent_complete.sh
|
||||
|
||||
#if ! command -v scp >/dev/null 2>&1; then
|
||||
# echo "Install scp"
|
||||
#
|
||||
# for i in {1..12}; do
|
||||
# if apk add --no-cache openssh-client; then
|
||||
# break
|
||||
# fi
|
||||
#
|
||||
# echo "Retry $i/12"
|
||||
# sleep 5
|
||||
# done
|
||||
#fi
|
||||
if ! command -v scp >/dev/null 2>&1; then
|
||||
echo "Install scp"
|
||||
|
||||
for i in {1..12}; do
|
||||
if apk add --no-cache openssh-client; then
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Retry $i/12"
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
|
||||
if [ ! -d /config/.venv ]; then
|
||||
echo "Generate venv"
|
||||
|
||||
@@ -72,8 +72,8 @@ fi
|
||||
TORRENT_NAME="$1"
|
||||
ROOT_PATH="$2"
|
||||
CATEGORY="$3"
|
||||
if [[ "$CATEGORY" != *arr* && "$CATEGORY" != "mytorrents" ]]; then
|
||||
|
||||
if [[ "$CATEGORY" != *arr* ]]; then
|
||||
###############################################################################
|
||||
# Start
|
||||
###############################################################################
|
||||
@@ -155,7 +155,7 @@ if [[ "$CATEGORY" != *arr* ]]; then
|
||||
lucas@mediahost "mkdir -p \"/torrent/qbittorrent/mediahost/$remote_dir\""
|
||||
SSH_STATUS=$?
|
||||
scp -i /config/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
-r "$ROOT_PATH"/. lucas@mediahost:/torrent/qbittorrent/mediahost/
|
||||
-r "$ROOT_PATH"/. lucas@mediahost:/torrent/qbittorrent/mediahost/$remote_dir/
|
||||
SCP_STATUS=$?
|
||||
|
||||
echo "scp exit status: $SCP_STATUS"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
services:
|
||||
qbittorrent:
|
||||
#image: "lscr.io/linuxserver/qbittorrent:latest"
|
||||
build: .
|
||||
container_name: "qbittorrent"
|
||||
qbittorrent-private:
|
||||
image: "lscr.io/linuxserver/qbittorrent:5.2.3"
|
||||
container_name: "qbittorrent-private"
|
||||
environment:
|
||||
- PGID=1000
|
||||
- TZ=Europe/Vienna
|
||||
@@ -16,8 +15,47 @@ services:
|
||||
|
||||
volumes:
|
||||
- "./10-torrent-init.sh:/custom-cont-init.d/10-torrent-init.sh:ro"
|
||||
- "./config:/config"
|
||||
- "./qbittorrent-private:/config"
|
||||
- "/home/lucas/scripts/torrent:/hostscripts:ro"
|
||||
- "/torrent/qbittorrent/downloads:/downloads"
|
||||
- "/torrent/qbittorrent/torrents:/torrents"
|
||||
- "/torrent/qbittorrent/watch:/watch"
|
||||
- "/torrent/qbittorrent-private/downloads:/downloads-private"
|
||||
- "/torrent/qbittorrent-private/torrents:/torrents"
|
||||
qbittorrent-public:
|
||||
image: "lscr.io/linuxserver/qbittorrent:5.2.3"
|
||||
container_name: "qbittorrent-public"
|
||||
environment:
|
||||
- PGID=1000
|
||||
- TZ=Europe/Vienna
|
||||
- PUID=1000
|
||||
- WEBUI_PORT=8080
|
||||
network_mode: "service:gluetun"
|
||||
depends_on:
|
||||
- gluetun
|
||||
restart: "unless-stopped"
|
||||
volumes:
|
||||
- "./10-torrent-init.sh:/custom-cont-init.d/10-torrent-init.sh:ro"
|
||||
- "./qbittorrent-public:/config"
|
||||
- "/home/lucas/scripts/torrent:/hostscripts:ro"
|
||||
- "/torrent/qbittorrent-public/downloads:/downloads-public"
|
||||
- "/torrent/qbittorrent-public/torrents:/torrents"
|
||||
gluetun:
|
||||
image: qmcgaw/gluetun:latest
|
||||
container_name: gluetun
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
ports:
|
||||
- 8082:8080 # qBittorrent WebUI
|
||||
- 55662:55662
|
||||
- 55662:55662/udp
|
||||
environment:
|
||||
- VPN_SERVICE_PROVIDER=custom
|
||||
- VPN_TYPE=wireguard
|
||||
- SOCKS5_ENABLED=on
|
||||
- HTTPPROXY=on
|
||||
- SOCKSPROXY=on
|
||||
- TZ=Europe/Vienna
|
||||
volumes:
|
||||
- ./gluetun:/gluetun/wireguard
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
#
|
||||
# Use this configuration with WireGuard client
|
||||
#
|
||||
[Interface]
|
||||
Address = 10.14.0.2/16
|
||||
PrivateKey = mKeDQdxtM1iFJS7WLlb2jPGabZIt3NkoA/nGCcNOU1I=
|
||||
DNS = 162.252.172.57, 149.154.159.92
|
||||
[Peer]
|
||||
PublicKey = dPZe8Jq3Hu0k07MDk+Y4+AS2XHSLYalyg91TSFXRYEA=
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 87.249.133.4:51820
|
||||
|
||||
Executable
+193
@@ -0,0 +1,193 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# Description:
|
||||
# qBittorrent external script that:
|
||||
# 1. Receives torrent name, root path and category from qBittorrent.
|
||||
# 2. Sends the torrent name to torrent_mqtt.py.
|
||||
# 3. Copies the torrent contents to mediahost via SCP.
|
||||
# 4. Logs all output to a timestamped logfile.
|
||||
#
|
||||
# Usage:
|
||||
# torrent_complete.sh <torrent_name> <root_path> <category>
|
||||
#
|
||||
# qBittorrent configuration:
|
||||
# /home/lucas/scripts/serverhost/torrent_complete.sh "%N" "%R" "%L"
|
||||
#
|
||||
# Arguments:
|
||||
# $1 Torrent name
|
||||
# $2 Root path
|
||||
# $3 Torrent category
|
||||
#
|
||||
# Log files:
|
||||
# /home/lucas/.LOG/log_torrent_name_YYYY-MM-DD_HH-MM-SS.log
|
||||
#
|
||||
# Requirements:
|
||||
# - bash
|
||||
# - scp
|
||||
# - SSH access to "mediahost"
|
||||
# - /home/lucas/scripts/serverhost/torrent_mqtt.py
|
||||
###############################################################################
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
###############################################################################
|
||||
# Usage
|
||||
###############################################################################
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage:
|
||||
$SCRIPT_NAME <torrent_name> <root_path> <category>
|
||||
|
||||
Description:
|
||||
Sends the torrent name to torrent_mqtt.py and copies the torrent
|
||||
contents to mediahost.
|
||||
|
||||
Arguments:
|
||||
torrent_name Torrent name
|
||||
root_path Torrent root path
|
||||
category Torrent category
|
||||
|
||||
Example:
|
||||
$SCRIPT_NAME "Ubuntu 24.04" "/downloads/Ubuntu 24.04" "Linux"
|
||||
|
||||
qBittorrent:
|
||||
$SCRIPT_NAME "%N" "%R" "%L"
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Check arguments
|
||||
###############################################################################
|
||||
|
||||
if [[ $# -ne 3 ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TORRENT_NAME="$1"
|
||||
ROOT_PATH="$2"
|
||||
CATEGORY="$3"
|
||||
if [[ "$CATEGORY" != *arr* && "$CATEGORY" != "mytorrents" ]]; then
|
||||
|
||||
###############################################################################
|
||||
# Start
|
||||
###############################################################################
|
||||
|
||||
echo "=================================================="
|
||||
echo "Torrent transfer started"
|
||||
echo "Date: $(date)"
|
||||
echo "Script: $SCRIPT_NAME"
|
||||
echo "Torrent name: $TORRENT_NAME"
|
||||
echo "Root path: $ROOT_PATH"
|
||||
echo "Category: $CATEGORY"
|
||||
echo "=================================================="
|
||||
|
||||
###############################################################################
|
||||
# Check for scp command
|
||||
###############################################################################
|
||||
|
||||
if ! command -v scp >/dev/null 2>&1; then
|
||||
echo "Install scp"
|
||||
sed -i 's|http://|https://|g' /etc/apk/repositories
|
||||
for i in {1..12}; do
|
||||
if apk add --no-cache openssh-client; then
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Retry $i/12"
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Send torrent name to MQTT script
|
||||
###############################################################################
|
||||
|
||||
echo
|
||||
echo "--- Sending torrent name to torrent_mqtt.py ---"
|
||||
|
||||
/config/.venv/bin/python3 /config/torrent_mqtt.py "$TORRENT_NAME"
|
||||
|
||||
MQTT_STATUS=$?
|
||||
|
||||
echo "torrent_mqtt.py exit status: $MQTT_STATUS"
|
||||
|
||||
###############################################################################
|
||||
# Check root path
|
||||
###############################################################################
|
||||
|
||||
echo
|
||||
echo "--- Checking root path ---"
|
||||
|
||||
if [[ ! -d "$ROOT_PATH" ]]; then
|
||||
echo "ERROR: Root path does not exist or is not a directory:"
|
||||
echo " $ROOT_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Root path exists."
|
||||
|
||||
###############################################################################
|
||||
# Copy torrent to mediahost
|
||||
###############################################################################
|
||||
|
||||
basename_root=$(basename "$ROOT_PATH")
|
||||
echo $basename_root
|
||||
if [ "$basename_root" = "downloads" ]; then
|
||||
remote_dir="$TORRENT_NAME"
|
||||
else
|
||||
remote_dir="$basename_root"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "--- Copying torrent to mediahost ---"
|
||||
echo "Source: $ROOT_PATH"
|
||||
echo "Destination: mediahost:/torrent/qbittorrent/mediahost/"
|
||||
|
||||
ssh -i /config/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
lucas@mediahost "mkdir -p \"/torrent/qbittorrent/mediahost/$remote_dir\""
|
||||
SSH_STATUS=$?
|
||||
scp -i /config/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
-r "$ROOT_PATH"/. lucas@mediahost:/torrent/qbittorrent/mediahost/$remote_dir/
|
||||
SCP_STATUS=$?
|
||||
|
||||
echo "scp exit status: $SCP_STATUS"
|
||||
|
||||
###############################################################################
|
||||
# Result
|
||||
###############################################################################
|
||||
|
||||
echo
|
||||
echo "=================================================="
|
||||
|
||||
if [[ $MQTT_STATUS -ne 0 ]]; then
|
||||
echo "WARNING: torrent_mqtt.py failed."
|
||||
fi
|
||||
if [[ $SSH_STATUS -eq 0 ]]; then
|
||||
echo "Target Dir generated"
|
||||
else
|
||||
echo "ERROR: Target dir generation failed."
|
||||
fi
|
||||
|
||||
if [[ $SCP_STATUS -eq 0 ]]; then
|
||||
echo "Torrent transfer completed successfully."
|
||||
else
|
||||
echo "ERROR: Torrent transfer failed."
|
||||
fi
|
||||
|
||||
echo "Finished: $(date +'%y%m%d %H%M%Sr')"
|
||||
echo "=================================================="
|
||||
else
|
||||
echo "ARR handled torrent: $(date +'%y%m%d %H%M%S'): $TORRENT_NAME"
|
||||
fi
|
||||
|
||||
# Return SCP's exit status to qBittorrent
|
||||
exit 0
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# Description:
|
||||
# qBittorrent external script that:
|
||||
# 1. Receives torrent name, root path and category from qBittorrent.
|
||||
# 2. Sends the torrent name to torrent_mqtt.py.
|
||||
# 3. Copies the torrent contents to mediahost via SCP.
|
||||
# 4. Logs all output to a timestamped logfile.
|
||||
#
|
||||
# Usage:
|
||||
# torrent_complete.sh <torrent_name> <root_path> <category>
|
||||
#
|
||||
# qBittorrent configuration:
|
||||
# /home/lucas/scripts/serverhost/torrent_complete.sh "%N" "%R" "%L"
|
||||
#
|
||||
# Arguments:
|
||||
# $1 Torrent name
|
||||
# $2 Root path
|
||||
# $3 Torrent category
|
||||
#
|
||||
# Log files:
|
||||
# /home/lucas/.LOG/log_torrent_name_YYYY-MM-DD_HH-MM-SS.log
|
||||
#
|
||||
# Requirements:
|
||||
# - bash
|
||||
# - scp
|
||||
# - SSH access to "mediahost"
|
||||
# - /home/lucas/scripts/serverhost/torrent_mqtt.py
|
||||
###############################################################################
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
###############################################################################
|
||||
# Usage
|
||||
###############################################################################
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage:
|
||||
$SCRIPT_NAME <torrent_name> <root_path> <category>
|
||||
|
||||
Description:
|
||||
Sends the torrent name to torrent_mqtt.py and copies the torrent
|
||||
contents to mediahost.
|
||||
|
||||
Arguments:
|
||||
torrent_name Torrent name
|
||||
root_path Torrent root path
|
||||
category Torrent category
|
||||
|
||||
Example:
|
||||
$SCRIPT_NAME "Ubuntu 24.04" "/downloads/Ubuntu 24.04" "Linux"
|
||||
|
||||
qBittorrent:
|
||||
$SCRIPT_NAME "%N" "%R" "%L"
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Check arguments
|
||||
###############################################################################
|
||||
|
||||
if [[ $# -ne 3 ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TORRENT_NAME="$1"
|
||||
ROOT_PATH="$2"
|
||||
CATEGORY="$3"
|
||||
if [[ "$CATEGORY" != *arr* && "$CATEGORY" != "mytorrents" ]]; then
|
||||
|
||||
###############################################################################
|
||||
# Start
|
||||
###############################################################################
|
||||
|
||||
echo "=================================================="
|
||||
echo "Torrent transfer started"
|
||||
echo "Date: $(date)"
|
||||
echo "Script: $SCRIPT_NAME"
|
||||
echo "Torrent name: $TORRENT_NAME"
|
||||
echo "Root path: $ROOT_PATH"
|
||||
echo "Category: $CATEGORY"
|
||||
echo "=================================================="
|
||||
|
||||
###############################################################################
|
||||
# Check for scp command
|
||||
###############################################################################
|
||||
|
||||
if ! command -v scp >/dev/null 2>&1; then
|
||||
echo "Install scp"
|
||||
sed -i 's|http://|https://|g' /etc/apk/repositories
|
||||
for i in {1..12}; do
|
||||
if apk add --no-cache openssh-client; then
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Retry $i/12"
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Send torrent name to MQTT script
|
||||
###############################################################################
|
||||
|
||||
echo
|
||||
echo "--- Sending torrent name to torrent_mqtt.py ---"
|
||||
|
||||
/config/.venv/bin/python3 /config/torrent_mqtt.py "$TORRENT_NAME"
|
||||
|
||||
MQTT_STATUS=$?
|
||||
|
||||
echo "torrent_mqtt.py exit status: $MQTT_STATUS"
|
||||
|
||||
###############################################################################
|
||||
# Check root path
|
||||
###############################################################################
|
||||
|
||||
echo
|
||||
echo "--- Checking root path ---"
|
||||
|
||||
if [[ ! -d "$ROOT_PATH" ]]; then
|
||||
echo "ERROR: Root path does not exist or is not a directory:"
|
||||
echo " $ROOT_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Root path exists."
|
||||
|
||||
###############################################################################
|
||||
# Copy torrent to mediahost
|
||||
###############################################################################
|
||||
|
||||
basename_root=$(basename "$ROOT_PATH")
|
||||
echo $basename_root
|
||||
if [ "$basename_root" = "downloads" ]; then
|
||||
remote_dir="$TORRENT_NAME"
|
||||
else
|
||||
remote_dir="$basename_root"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "--- Copying torrent to mediahost ---"
|
||||
echo "Source: $ROOT_PATH"
|
||||
echo "Destination: mediahost:/torrent/qbittorrent/mediahost/"
|
||||
|
||||
ssh -i /config/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
lucas@mediahost "mkdir -p \"/torrent/qbittorrent/mediahost/$remote_dir\""
|
||||
SSH_STATUS=$?
|
||||
scp -i /config/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
-r "$ROOT_PATH"/. lucas@mediahost:/torrent/qbittorrent/mediahost/$remote_dir/
|
||||
SCP_STATUS=$?
|
||||
|
||||
echo "scp exit status: $SCP_STATUS"
|
||||
|
||||
###############################################################################
|
||||
# Result
|
||||
###############################################################################
|
||||
|
||||
echo
|
||||
echo "=================================================="
|
||||
|
||||
if [[ $MQTT_STATUS -ne 0 ]]; then
|
||||
echo "WARNING: torrent_mqtt.py failed."
|
||||
fi
|
||||
if [[ $SSH_STATUS -eq 0 ]]; then
|
||||
echo "Target Dir generated"
|
||||
else
|
||||
echo "ERROR: Target dir generation failed."
|
||||
fi
|
||||
|
||||
if [[ $SCP_STATUS -eq 0 ]]; then
|
||||
echo "Torrent transfer completed successfully."
|
||||
else
|
||||
echo "ERROR: Torrent transfer failed."
|
||||
fi
|
||||
|
||||
echo "Finished: $(date +'%y%m%d %H%M%Sr')"
|
||||
echo "=================================================="
|
||||
else
|
||||
echo "ARR handled torrent: $(date +'%y%m%d %H%M%S'): $TORRENT_NAME"
|
||||
fi
|
||||
|
||||
# Return SCP's exit status to qBittorrent
|
||||
exit 0
|
||||
|
||||
-1375
File diff suppressed because it is too large.
Load diff
@@ -1,183 +0,0 @@
|
||||
#!/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
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/bash
|
||||
FOLDER=/data/nextcloud/syncuser/pictures/Camera/
|
||||
|
||||
export PYTHONPATH=/home/lucas/scripts
|
||||
syncNC -c
|
||||
/home/lucas/scripts/file\ processing/rename_camera_folder.py ${FOLDER}
|
||||
|
||||
syncNC -c
|
||||
@@ -1,35 +1,45 @@
|
||||
#!/home/lucas/scripts/.venv/bin/python3
|
||||
import os
|
||||
import re
|
||||
import random
|
||||
import datetime
|
||||
import sys
|
||||
from PIL import Image, ExifTags
|
||||
from pillow_heif import open_heif
|
||||
import subprocess
|
||||
import json
|
||||
from serverlibary import safe_rename
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import ExifTags, Image
|
||||
|
||||
from serverlibrary import safe_rename
|
||||
|
||||
# Folder to scan
|
||||
folder_path = '.'
|
||||
folder_path = "."
|
||||
# Supported image and video extensions
|
||||
|
||||
|
||||
video_extensions = {
|
||||
'.mp4', '.mov', '.avi', '.mkv', '.hevc', '.mov',
|
||||
".mp4",
|
||||
".mov",
|
||||
".avi",
|
||||
".mkv",
|
||||
".hevc",
|
||||
}
|
||||
img_extensions = {
|
||||
'.jpg', '.jpeg', '.png', '.heic', '.webp',
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".heic",
|
||||
".webp",
|
||||
}
|
||||
|
||||
extensions = img_extensions | video_extensions
|
||||
prefixes = { 'IMG', 'VID'}
|
||||
prefixes = {"IMG", "VID"}
|
||||
# Build reusable groups for regex
|
||||
ext_group = r'(jpg|jpeg|png|heic|webp|mp4|mov|avi|mkv|hevc)'
|
||||
prefix_group = r'(?:IMG|VID)'
|
||||
ext_group = r"(jpg|jpeg|png|heic|webp|mp4|mov|avi|mkv|hevc)"
|
||||
prefix_group = r"(?:IMG|VID)"
|
||||
# Correct format to keep
|
||||
correct_format_re = re.compile(
|
||||
rf'^{prefix_group}_(\d{{8}}_\d{{6}}).*\.{ext_group}$',
|
||||
re.IGNORECASE
|
||||
rf"^{prefix_group}_(\d{{8}}_\d{{6}}).*\.{ext_group}$", re.IGNORECASE
|
||||
)
|
||||
|
||||
# Alternate timestamp formats and their patterns
|
||||
@@ -37,51 +47,40 @@ alt_patterns = [
|
||||
# PREFIX_YYYY_MM_DD_HH_MM_SS.ext
|
||||
(
|
||||
re.compile(
|
||||
rf'^(?:{prefix_group}_)?(\d{{4}})_(\d{{2}})_(\d{{2}})_(\d{{2}})_(\d{{2}})_(\d{{2}}).*\.{ext_group}$',
|
||||
re.IGNORECASE
|
||||
rf"^(?:{prefix_group}_)?(\d{{4}})_(\d{{2}})_(\d{{2}})_(\d{{2}})_(\d{{2}})_(\d{{2}}).*\.{ext_group}$",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
'%Y_%m_%d_%H_%M_%S'
|
||||
"%Y_%m_%d_%H_%M_%S",
|
||||
),
|
||||
|
||||
# PREFIX_YYYYMMDD_HHMMSS.ext
|
||||
(
|
||||
re.compile(
|
||||
rf'^(?:{prefix_group}_)?(\d{{8}})_(\d{{6}}).*\.{ext_group}$',
|
||||
re.IGNORECASE
|
||||
rf"^(?:{prefix_group}_)?(\d{{8}})_(\d{{6}}).*\.{ext_group}$", re.IGNORECASE
|
||||
),
|
||||
'%Y%m%d_%H%M%S'
|
||||
"%Y%m%d_%H%M%S",
|
||||
),
|
||||
|
||||
# PREFIX_YYYY.MM.DD-HHMMSS.ext
|
||||
(
|
||||
re.compile(
|
||||
rf'^(?:{prefix_group}_)?(\d{{4}})\.(\d{{2}})\.(\d{{2}})-(\d{{6}}).*\.{ext_group}$',
|
||||
re.IGNORECASE
|
||||
rf"^(?:{prefix_group}_)?(\d{{4}})\.(\d{{2}})\.(\d{{2}})-(\d{{6}}).*\.{ext_group}$",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
'%Y.%m.%d-%H%M%S'
|
||||
"%Y.%m.%d-%H%M%S",
|
||||
),
|
||||
|
||||
# PREFIX_YYYYMMDD.ext
|
||||
(
|
||||
re.compile(
|
||||
rf'^(?:{prefix_group}_)?(\d{{8}})\.{ext_group}$',
|
||||
re.IGNORECASE
|
||||
re.compile(rf"^(?:{prefix_group}_)?(\d{{8}})\.{ext_group}$", re.IGNORECASE),
|
||||
"%Y%m%d",
|
||||
),
|
||||
'%Y%m%d'
|
||||
),
|
||||
|
||||
# PREFIX_HHMMSS.ext
|
||||
(
|
||||
re.compile(
|
||||
rf'^(?:{prefix_group}_)?(\d{{6}})\.{ext_group}$',
|
||||
re.IGNORECASE
|
||||
),
|
||||
'%H%M%S'
|
||||
re.compile(rf"^(?:{prefix_group}_)?(\d{{6}})\.{ext_group}$", re.IGNORECASE),
|
||||
"%H%M%S",
|
||||
),
|
||||
]
|
||||
|
||||
# EXIF tag name for datetime
|
||||
EXIF_DATE_TAGS = ['DateTimeOriginal', 'DateTimeDigitized', 'DateTime']
|
||||
EXIF_DATE_TAGS = ["DateTimeOriginal", "DateTimeDigitized", "DateTime"]
|
||||
|
||||
|
||||
def local_safe_rename(src_path, new_filename):
|
||||
@@ -98,21 +97,20 @@ def local_safe_rename(src_path, new_filename):
|
||||
os.rename(src_path, os.path.join(directory, final_name))
|
||||
print(f"Renamed: {os.path.basename(src_path)} ➜ {final_name}")
|
||||
|
||||
|
||||
def get_exif_creation_datetime(filepath):
|
||||
"""Try to extract creation datetime from image EXIF metadata"""
|
||||
try:
|
||||
if filepath.lower().endswith((".mp4", ".mov", ".m4v")):
|
||||
# WhatsApp videos: use filesystem modification time
|
||||
if "WA" in os.path.basename(filepath):
|
||||
return datetime.datetime.fromtimestamp(
|
||||
os.path.getmtime(filepath)
|
||||
)
|
||||
return datetime.datetime.fromtimestamp(os.path.getmtime(filepath))
|
||||
|
||||
tags = ["CreateDate", "MediaCreateDate", "TrackCreateDate"]
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"exiftool",
|
||||
"/usr/bin/exiftool",
|
||||
"-j",
|
||||
"-CreateDate",
|
||||
"-MediaCreateDate",
|
||||
@@ -129,14 +127,11 @@ def get_exif_creation_datetime(filepath):
|
||||
for tag in tags:
|
||||
if metadata.get(tag):
|
||||
return datetime.datetime.strptime(
|
||||
metadata[tag],
|
||||
"%Y:%m:%d %H:%M:%S"
|
||||
metadata[tag], "%Y:%m:%d %H:%M:%S"
|
||||
)
|
||||
|
||||
# Fallback: use filesystem modification time
|
||||
return datetime.datetime.fromtimestamp(
|
||||
os.path.getmtime(filepath)
|
||||
)
|
||||
return datetime.datetime.fromtimestamp(os.path.getmtime(filepath))
|
||||
|
||||
else:
|
||||
with Image.open(filepath) as img:
|
||||
@@ -156,23 +151,22 @@ def get_exif_creation_datetime(filepath):
|
||||
if tag in tag_label:
|
||||
print(tag)
|
||||
return datetime.datetime.strptime(
|
||||
tag_label[tag],
|
||||
'%Y:%m:%d %H:%M:%S'
|
||||
tag_label[tag], "%Y:%m:%d %H:%M:%S"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Exif Error {e}")
|
||||
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_manufacturer(file_path):
|
||||
# Call exiftool and get JSON output
|
||||
result = subprocess.run(
|
||||
["exiftool", "-j", file_path], # -j outputs JSON
|
||||
["/usr/bin/exiftool", "-j", file_path], # -j outputs JSON
|
||||
capture_output=True,
|
||||
text=True
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Parse JSON output
|
||||
@@ -190,6 +184,7 @@ def get_manufacturer(file_path):
|
||||
author_no_spaces = author.replace(" ", "")
|
||||
return author_no_spaces
|
||||
|
||||
|
||||
def process_images(folder):
|
||||
folder = Path(folder)
|
||||
for fname in os.listdir(folder):
|
||||
@@ -205,7 +200,7 @@ def process_images(folder):
|
||||
continue
|
||||
|
||||
# Step 1: Normalize filename by replacing '-' with '_'
|
||||
normalized_name = fname.replace('-', '_')
|
||||
normalized_name = fname.replace("-", "_")
|
||||
|
||||
if normalized_name != fname:
|
||||
# Rename the file to the normalized name before continuing
|
||||
@@ -231,11 +226,14 @@ def process_images(folder):
|
||||
|
||||
# Now continue with your existing logic on normalized fname
|
||||
if correct_format_re.match(fname):
|
||||
if videoManufacturer != "" and file_prefix == "VID":
|
||||
if videoManufacturer not in fname:
|
||||
if (
|
||||
videoManufacturer != ""
|
||||
and file_prefix == "VID"
|
||||
and videoManufacturer not in fname
|
||||
):
|
||||
basename, _ = os.path.splitext(fname)
|
||||
new_fname = f"{basename}{videoManufacturer}{ext}"
|
||||
safe_rename(Path(fpath),folder / new_fname)
|
||||
safe_rename(Path(fpath), folder / new_fname)
|
||||
continue
|
||||
|
||||
print(f"[OK] {fname} already correctly named.")
|
||||
@@ -247,13 +245,14 @@ def process_images(folder):
|
||||
if m:
|
||||
try:
|
||||
parts = m.groups()
|
||||
timestamp_str = '_'.join(parts[:2]) if len(parts) >= 2 else parts[0]
|
||||
timestamp_str = "_".join(parts[:2]) if len(parts) >= 2 else parts[0]
|
||||
dt = datetime.datetime.strptime(timestamp_str, fmt)
|
||||
new_fname = f"{file_prefix}_{dt.strftime('%Y%m%d_%H%M%S')}{videoManufacturer}{ext}"
|
||||
safe_rename(Path(fpath),folder / new_fname)
|
||||
safe_rename(Path(fpath), folder / new_fname)
|
||||
matched = True
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Exception {e}")
|
||||
continue
|
||||
|
||||
if matched:
|
||||
@@ -262,32 +261,35 @@ def process_images(folder):
|
||||
# 3. Try reading creation date from metadata
|
||||
dt = get_exif_creation_datetime(fpath)
|
||||
if dt:
|
||||
new_fname = f"{file_prefix}_{dt.strftime('%Y%m%d_%H%M%S')}{videoManufacturer}{ext}"
|
||||
safe_rename(Path(fpath),folder / new_fname)
|
||||
new_fname = (
|
||||
f"{file_prefix}_{dt.strftime('%Y%m%d_%H%M%S')}{videoManufacturer}{ext}"
|
||||
)
|
||||
safe_rename(Path(fpath), folder / new_fname)
|
||||
continue
|
||||
else:
|
||||
print(f"[Fail] Creation Date for {fpath} not found")
|
||||
# 4. Fallback: keep original name, add _RX_<random> (only if not already added)
|
||||
if '_RX_' in fname:
|
||||
if "_RX_" in fname:
|
||||
print(f"[SKIP] {fname} already has fallback name.")
|
||||
continue
|
||||
|
||||
basename, _ = os.path.splitext(fname)
|
||||
rand_num = random.randint(100000, 999999)
|
||||
new_fname = f"{basename}_RX_{rand_num}{videoManufacturer}{ext}"
|
||||
safe_rename(Path(fpath),folder / new_fname)
|
||||
safe_rename(Path(fpath), folder / new_fname)
|
||||
|
||||
|
||||
# ---- Main script entry ----
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python script.py <image_folder_path>")
|
||||
sys.exit(1)
|
||||
|
||||
folder_path = "/data/nextcloud/syncuser/pictures/Camera"
|
||||
else:
|
||||
folder_path = sys.argv[1]
|
||||
|
||||
if not os.path.isdir(folder_path):
|
||||
print(f"Error: '{folder_path}' is not a valid directory.")
|
||||
sys.exit(1)
|
||||
|
||||
subprocess.run(["/usr/bin/syncNC", "-c"], check=True)
|
||||
print(f"Processing images in: {os.path.abspath(folder_path)}\n")
|
||||
process_images(folder_path)
|
||||
subprocess.run(["/usr/bin/syncNC", "-c"], check=True)
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
syncNC -c && rsync -avz --info=progress2 --delete --exclude "Camera/" --exclude "Alina Share/" --exclude "*.db" /data/nextcloud/syncuser/pictures/ /data/immich/pictures/
|
||||
@@ -1 +0,0 @@
|
||||
find . -type f -name '*conflict**' -exec sh -c 'x="{}"; mv "$x" "$(echo "$x" | sed "s/ (conflicted copy 2025-01-06 190022)//g")" ' \;
|
||||
@@ -1,98 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
commit1=$1
|
||||
commit2=$2
|
||||
dir=$3
|
||||
patches_dir=$4
|
||||
CHECK=1
|
||||
APPLY=1
|
||||
|
||||
if [ -z "$commit1" ] || [ -z "$commit2" ] || [ -z "$dir" ]; then
|
||||
echo "Usage: $0 <commit1> <commit2> <directory> [patches_dir]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Default patches directory if not provided
|
||||
if [ -z "$patches_dir" ]; then
|
||||
patches_dir="$(pwd)/patches"
|
||||
elif [[ "$patches_dir" != /* ]]; then
|
||||
patches_dir="$(pwd)/${patches_dir}patches"
|
||||
fi
|
||||
|
||||
if [[ "$commit2" == "HEAD" ]]; then
|
||||
commit2=""
|
||||
fi
|
||||
|
||||
echo "Diffing $dir from $commit1 to ${commit2:-HEAD}..."
|
||||
echo "Patches will be saved in: $patches_dir"
|
||||
|
||||
|
||||
# Save current directory
|
||||
orig_dir="$(pwd)"
|
||||
|
||||
# Get git root directory relative to $dir
|
||||
git_root=$(git -C "$dir" rev-parse --show-toplevel) || { echo "Not a git repo: $dir"; exit 1; }
|
||||
|
||||
# Compute relative path of $dir from git root
|
||||
relative_dir=$(realpath --relative-to="$git_root" "$dir")
|
||||
echo "Relative path of $dir from git root: $relative_dir"
|
||||
|
||||
# Change to git root directory
|
||||
cd "$git_root" || { echo "Failed to cd to git root of $dir"; exit 1; }
|
||||
echo "Go to $git_root"
|
||||
mkdir -p "$patches_dir"
|
||||
|
||||
# Load list of changed files into an array
|
||||
mapfile -t diff_files < <(git diff --name-only $commit1..$commit -- $relative_dir )
|
||||
|
||||
|
||||
for file in "${diff_files[@]}"; do
|
||||
git diff $commit1..$commit2 --stat -- "$file"
|
||||
done
|
||||
|
||||
for file in "${diff_files[@]}"; do
|
||||
echo $file
|
||||
git diff $commit1..$commit2 -- "$file" > "$patches_dir/$(basename "$file").patch"
|
||||
done
|
||||
for patch in "$patches_dir"/*.patch; do
|
||||
echo $patch
|
||||
for patch in "$patches_dir"/*.patch; do
|
||||
sed -i 's|trixel3_software/||g' "$patch"
|
||||
done
|
||||
done
|
||||
|
||||
# Get git root directory relative to $dir
|
||||
git_root_patches_dir=$(git -C "$patches_dir" rev-parse --show-toplevel) || { echo "Not a git repo: $patches_dir"; exit 1; }
|
||||
|
||||
# Compute relative path of $dir from git root
|
||||
relative_dir_patches_dir=$(realpath --relative-to="$git_root_patches_dir" "$patches_dir")
|
||||
echo "Relative path of $patches_dir from git root: $relative_dir_patches_dir"
|
||||
|
||||
# Change to git root directory
|
||||
cd "$git_root_patches_dir" || { echo "Failed to cd to git root of $patches_dir"; exit 1; }
|
||||
echo "Go to $git_root_patches_dir"
|
||||
|
||||
if [[ $CHECK -eq 1 ]]; then
|
||||
# check patches
|
||||
echo "CHECK Patches"
|
||||
for patch in "$patches_dir"/*.patch; do
|
||||
echo $patch
|
||||
git apply --check "$patch"
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ $APPLY -eq 1 ]]; then
|
||||
# apply patches
|
||||
echo "APPLY Patches"
|
||||
for patch in "$patches_dir"/*.patch; do
|
||||
if git apply "$patch"; then
|
||||
echo "Applied $patch successfully."
|
||||
rm "$patch"
|
||||
else
|
||||
echo "Failed to apply $patch. Skipping."
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Return to original directory
|
||||
cd "$orig_dir" || exit 1
|
||||
File renamed without changes.
Executable
+305
@@ -0,0 +1,305 @@
|
||||
#!/home/lucas/scripts/.venv/bin/python
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def interpolate_daily(df):
|
||||
df = df.copy()
|
||||
df["Datum"] = pd.to_datetime(df["Datum"], errors="coerce")
|
||||
df = df.sort_values("Datum").set_index("Datum")
|
||||
|
||||
daily_index = pd.date_range(df.index.min(), df.index.max(), freq="D")
|
||||
|
||||
return (
|
||||
df.reindex(daily_index)
|
||||
.interpolate(method="time")
|
||||
.reset_index()
|
||||
.rename(columns={"index": "Datum"})
|
||||
)
|
||||
|
||||
|
||||
def plot_percentiles(
|
||||
ax,
|
||||
x,
|
||||
data_dict,
|
||||
p_keys,
|
||||
colors=None,
|
||||
linewidths=None,
|
||||
labels=None,
|
||||
linestyle=None,
|
||||
fill=True,
|
||||
fill_color="lightcoral",
|
||||
fill_alpha=0.25,
|
||||
):
|
||||
"""
|
||||
Plot multiple percentile curves from a dictionary like who_head_daily.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ax : matplotlib axis
|
||||
x : array-like (x values)
|
||||
data_dict : dict-like, e.g. who_head_daily
|
||||
p_keys : list of keys, e.g. ["P3", "P50", "P97"]
|
||||
colors : list of colors (optional)
|
||||
linewidths : list of linewidths (optional)
|
||||
fill : whether to fill between min and max percentile
|
||||
:param labels:
|
||||
"""
|
||||
|
||||
n = len(x)
|
||||
|
||||
if labels is None:
|
||||
labels = [None] * len(p_keys)
|
||||
if linestyle is None:
|
||||
linestyle = [None] * len(p_keys)
|
||||
if colors is None:
|
||||
colors = [None] * len(p_keys)
|
||||
if linewidths is None:
|
||||
linewidths = [1.5] * len(p_keys)
|
||||
|
||||
# Plot lines
|
||||
for i, key in enumerate(p_keys):
|
||||
ax.plot(
|
||||
x,
|
||||
data_dict[key][:n],
|
||||
color=colors[i],
|
||||
linewidth=linewidths[i],
|
||||
label=labels[i],
|
||||
linestyle=linestyle[i],
|
||||
)
|
||||
|
||||
# Fill between min and max percentile
|
||||
if fill and len(p_keys) >= 2:
|
||||
lower = data_dict[p_keys[0]][:n]
|
||||
upper = data_dict[p_keys[-1]][:n]
|
||||
|
||||
ax.fill_between(x, lower, upper, color=fill_color, alpha=fill_alpha)
|
||||
|
||||
return ax
|
||||
|
||||
|
||||
result = subprocess.run(["syncNC", "-al"], check=True)
|
||||
alina_path = Path("/data/nextcloud/syncuser/Wachstum")
|
||||
ods_path = alina_path / "WachstumsDatenWHO.ods"
|
||||
csv_path = alina_path / "WachstumAlina.csv"
|
||||
output_path = alina_path / "WachstumAlina.png"
|
||||
|
||||
output_dir = ods_path.name
|
||||
|
||||
# Load specific sheets into data structures (DataFrames)
|
||||
alina_df = pd.read_csv(csv_path)
|
||||
|
||||
who_weight_df = pd.read_excel(ods_path, sheet_name="WHO Gewicht", engine="odf")
|
||||
who_height_df = pd.read_excel(ods_path, sheet_name="WHO Größe", engine="odf")
|
||||
who_head_df = pd.read_excel(ods_path, sheet_name="WHO Kopfumfang", engine="odf")
|
||||
|
||||
|
||||
def calculateDateFromBrithday(df, birthday):
|
||||
|
||||
new_df = pd.DataFrame(columns=["Datum"])
|
||||
for idx, (val1, val2) in enumerate(zip(df["Month"], df["Week"])):
|
||||
if not pd.isna(val1):
|
||||
new_df.loc[idx, "Datum"] = birthday + pd.DateOffset(months=val1)
|
||||
if not pd.isna(val2):
|
||||
new_df.loc[idx, "Datum"] = birthday + pd.DateOffset(weeks=val2)
|
||||
|
||||
return pd.to_datetime(new_df["Datum"], format="%d.%m.%Y", errors="coerce")
|
||||
|
||||
|
||||
alina_df["Datum"] = pd.to_datetime(
|
||||
alina_df["Datum"], format="%d.%m.%Y", errors="coerce"
|
||||
)
|
||||
|
||||
# Ensure Datum is datetime (safe to run even if already converted)
|
||||
who_weight_df["Datum"] = calculateDateFromBrithday(who_weight_df, alina_df["Datum"][0])
|
||||
who_height_df["Datum"] = calculateDateFromBrithday(who_height_df, alina_df["Datum"][0])
|
||||
who_head_df["Datum"] = calculateDateFromBrithday(who_head_df, alina_df["Datum"][0])
|
||||
|
||||
who_weight_daily = interpolate_daily(who_weight_df)
|
||||
who_height_daily = interpolate_daily(who_height_df)
|
||||
who_head_daily = interpolate_daily(who_head_df)
|
||||
|
||||
print(alina_df["Datum"])
|
||||
# Plot
|
||||
last_alina_date = alina_df["Datum"].max() + pd.DateOffset(months=2)
|
||||
x = who_weight_daily["Datum"][who_weight_daily["Datum"] <= last_alina_date]
|
||||
fig, axes = plt.subplots(nrows=3, ncols=1, figsize=(12, 10), sharex=True)
|
||||
|
||||
percentile_list = ["P1", "P3", "P10", "P25", "P50", "P75", "P90", "P97", "P99"]
|
||||
colors_list = ["lightcoral" if p != "P50" else "darkred" for p in percentile_list]
|
||||
linewidths_list = [1.5 if p != "P50" else 2.5 for p in percentile_list]
|
||||
label_list = [
|
||||
p if p == "P50" or p == "P3" or p == "P97" else "" for p in percentile_list
|
||||
]
|
||||
linestyle_list = ["--" if p == "P3" or p == "P97" else "-" for p in percentile_list]
|
||||
# ---------- 1. WEIGHT ----------
|
||||
ax = axes[0]
|
||||
plot_percentiles(
|
||||
ax,
|
||||
x,
|
||||
who_weight_daily,
|
||||
percentile_list,
|
||||
colors=colors_list,
|
||||
linewidths=linewidths_list,
|
||||
labels=label_list,
|
||||
linestyle=linestyle_list,
|
||||
)
|
||||
|
||||
ax.plot(
|
||||
alina_df["Datum"],
|
||||
alina_df["Gewicht"].interpolate(limit_area="inside"),
|
||||
color="green",
|
||||
linewidth=1.5,
|
||||
marker="o",
|
||||
label="Alina",
|
||||
)
|
||||
|
||||
last_idx = alina_df["Gewicht"].last_valid_index()
|
||||
who_row = who_weight_daily.loc[
|
||||
who_weight_daily["Datum"] == alina_df["Datum"][last_idx]
|
||||
].iloc[0]
|
||||
perzentil = (alina_df["Gewicht"][last_idx] - who_row["P50"]) / (
|
||||
(who_row["P97"] - who_row["P50"]) / 47
|
||||
) + 50
|
||||
|
||||
ax.text(
|
||||
alina_df["Datum"].iloc[-1] + pd.DateOffset(days=1),
|
||||
alina_df["Gewicht"].iloc[-1] + 0.5,
|
||||
f"{perzentil:.2f} %",
|
||||
fontsize=10,
|
||||
ha="left",
|
||||
va="bottom",
|
||||
color="green",
|
||||
)
|
||||
|
||||
ax.set_ylabel("Gewicht [kg]")
|
||||
ax.set_title("Wachstum")
|
||||
# Enable minor ticks
|
||||
ax.minorticks_on()
|
||||
|
||||
# Configure major grid (e.g., solid, darker)
|
||||
ax.grid(which="major", color="black", linestyle="-", linewidth=0.8)
|
||||
|
||||
# Configure minor grid (e.g., dashed, lighter, thinner)
|
||||
ax.grid(which="minor", color="gray", linestyle="--", linewidth=0.4, alpha=0.5)
|
||||
ax.legend()
|
||||
|
||||
|
||||
# ---------- 2. HEIGHT ----------
|
||||
ax = axes[1]
|
||||
plot_percentiles(
|
||||
ax,
|
||||
x,
|
||||
who_height_daily,
|
||||
percentile_list,
|
||||
colors=colors_list,
|
||||
linewidths=linewidths_list,
|
||||
labels=label_list,
|
||||
linestyle=linestyle_list,
|
||||
)
|
||||
ax.plot(
|
||||
alina_df["Datum"],
|
||||
alina_df["Größe"].interpolate(limit_area="inside"),
|
||||
color="green",
|
||||
linewidth=1.5,
|
||||
marker="o",
|
||||
)
|
||||
|
||||
last_idx = alina_df["Größe"].last_valid_index()
|
||||
who_row = who_height_daily.loc[
|
||||
who_height_daily["Datum"] == alina_df["Datum"][last_idx]
|
||||
].iloc[0]
|
||||
perzentil = (alina_df["Größe"][last_idx] - who_row["P50"]) / (
|
||||
(who_row["P97"] - who_row["P50"]) / 47
|
||||
) + 50
|
||||
|
||||
ax.text(
|
||||
alina_df["Datum"].iloc[-1] + pd.DateOffset(days=1),
|
||||
alina_df["Größe"].iloc[-1] + 0.5,
|
||||
f"{perzentil:.2f} %",
|
||||
fontsize=10,
|
||||
ha="left",
|
||||
va="bottom",
|
||||
color="green",
|
||||
)
|
||||
|
||||
|
||||
ax.set_ylabel("Größe [cm]")
|
||||
# Enable minor ticks
|
||||
ax.minorticks_on()
|
||||
|
||||
# Configure major grid (e.g., solid, darker)
|
||||
ax.grid(which="major", color="black", linestyle="-", linewidth=0.8)
|
||||
# Configure minor grid (e.g., dashed, lighter, thinner)
|
||||
ax.grid(which="minor", color="gray", linestyle="--", linewidth=0.4, alpha=0.5)
|
||||
|
||||
|
||||
# ---------- 3. HEAD CIRCUMFERENCE ----------
|
||||
ax = axes[2]
|
||||
|
||||
plot_percentiles(
|
||||
ax,
|
||||
x,
|
||||
who_head_daily,
|
||||
percentile_list,
|
||||
colors=colors_list,
|
||||
linewidths=linewidths_list,
|
||||
labels=label_list,
|
||||
linestyle=linestyle_list,
|
||||
)
|
||||
|
||||
ax.plot(
|
||||
alina_df["Datum"],
|
||||
alina_df["Kopfumfang"].interpolate(limit_area="inside"),
|
||||
color="green",
|
||||
linewidth=1.5,
|
||||
marker="o",
|
||||
)
|
||||
|
||||
last_idx = alina_df["Kopfumfang"].last_valid_index()
|
||||
who_row = who_head_daily.loc[
|
||||
who_head_daily["Datum"] == alina_df["Datum"][last_idx]
|
||||
].iloc[0]
|
||||
perzentil = (alina_df["Kopfumfang"][last_idx] - who_row["P50"]) / (
|
||||
(who_row["P97"] - who_row["P50"]) / 47
|
||||
) + 50
|
||||
|
||||
ax.text(
|
||||
alina_df["Datum"].iloc[-1] + pd.DateOffset(days=1),
|
||||
alina_df["Kopfumfang"].iloc[-1] + 0.5,
|
||||
f"{perzentil:.2f} %",
|
||||
fontsize=10,
|
||||
ha="left",
|
||||
va="bottom",
|
||||
color="green",
|
||||
)
|
||||
|
||||
ax.set_ylabel("Kopfumfang [cm]")
|
||||
# Enable minor ticks
|
||||
ax.minorticks_on()
|
||||
|
||||
# Configure major grid (e.g., solid, darker)
|
||||
ax.grid(which="major", color="black", linestyle="-", linewidth=0.8)
|
||||
|
||||
# Configure minor grid (e.g., dashed, lighter, thinner)
|
||||
ax.grid(which="minor", color="gray", linestyle="--", linewidth=0.4, alpha=0.5)
|
||||
|
||||
# ---------- X‑AXIS ----------
|
||||
axes[-1].set_xlabel("Datum")
|
||||
|
||||
N = 7 # label every Nth tick
|
||||
|
||||
axes[-1].set_xticks(x[::N])
|
||||
axes[-1].set_xticklabels(x.iloc[::N].dt.strftime("%d.%m.%Y"), rotation=45, ha="right")
|
||||
|
||||
plt.tight_layout()
|
||||
plt.savefig(output_path, dpi=150)
|
||||
plt.close()
|
||||
|
||||
subprocess.run(["syncNC", "-al"], check=True)
|
||||
|
||||
print("Fin")
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/bin/bash
|
||||
if [ "$2" == "-c" ];then
|
||||
blue="\e[34m"
|
||||
red="\e[31m"
|
||||
norm="\e[0m"
|
||||
else
|
||||
blue=""
|
||||
red=""
|
||||
norm=""
|
||||
fi
|
||||
declare -a hosts=("serverhost" "workhost" "quiethost" "localhost")
|
||||
if [ "$#" -eq 0 ];then
|
||||
printf "Arguments: hosts, netw\n"
|
||||
fi
|
||||
if [ "$1" == "hosts" ];then
|
||||
for host in "${hosts[@]}"; do
|
||||
ping -c 2 -W 1 $host > /dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
printf "%s\t\t[$blue UP$norm ]\n" "$host" | sed "s/localhost/$(hostname)/g"
|
||||
else
|
||||
printf "%s\t\t[$red DOWN$norm ]\n" "$host"
|
||||
fi
|
||||
done
|
||||
elif [ "$1" == "netw" ];then
|
||||
if [ "$(ifconfig | grep -A2 eth0 | grep inet)" != "" ]; then
|
||||
if="eth0"
|
||||
elif [ "$(ifconfig | grep -A2 wlan0 | grep inet)" != "" ]; then
|
||||
if="wlan0"
|
||||
fi
|
||||
#netw="$(ifconfig $if | grep -m1 -o -P '(?<=inet ).*(?= netmask)' | grep -o '[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.')"
|
||||
netw="$( ifconfig $if | grep -o '[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.' | grep -m1 -v 255)"
|
||||
for i in {1..254}; do
|
||||
ping -c 1 -W 1 $netw$i > /dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
# hostname="$(ssh 10.0.0.$i hostname)"
|
||||
# if [ ! $? -eq 0 ]; then
|
||||
hostname="$(nbtscan 10.0.0.$i | grep -A1 "\-\-\-\-\-" | sed 's/ */ /g' | cut -d' ' -f2 | grep '[A-Za-z]')"
|
||||
# fi
|
||||
printf "10.0.0.%s\t[$blue UP$norm ]\t%s\n" "$i" "$hostname"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
-105
@@ -1,105 +0,0 @@
|
||||
#!/bin/bash
|
||||
NEW_TYPE="png"
|
||||
TYPE_ARR=("jp*g" "tif" "webp" "png")
|
||||
file=""
|
||||
folder=""
|
||||
usage(){
|
||||
printf "Usage:
|
||||
-f <file>\tConvert single File
|
||||
-d <dir>\tConvert all files in Dir
|
||||
-t <type>\tSet new Type
|
||||
-h\t\tDisplay this\n"
|
||||
}
|
||||
|
||||
img_convert() {
|
||||
IMG="$1"
|
||||
IMG_NEW="$IMG"
|
||||
for type in ${TYPE_ARR[@]}; do
|
||||
IMG_NEW="$(echo ${IMG_NEW} | sed 's/\.'"${type}"'/.'"${NEW_TYPE}"'/g')"
|
||||
done
|
||||
if [ ! -f "${IMG}" ]; then
|
||||
echo "${IMG} not found"
|
||||
return
|
||||
fi
|
||||
if [ "${IMG}" == "${IMG_NEW}" ]; then
|
||||
echo "${IMG} and ${IMG_NEW} are the same"
|
||||
return
|
||||
fi
|
||||
echo "Convert ${IMG} ${IMG_NEW}"
|
||||
convert -quiet "${IMG}" "${IMG_NEW}" >> /dev/null
|
||||
rm "${IMG}"
|
||||
}
|
||||
|
||||
folder_magic(){
|
||||
IMG_FOLDER="$1"
|
||||
echo "Convert Imgs in ${IMG_FOLDER}"
|
||||
IMG_ARR=()
|
||||
IMG_ARR_NEW=()
|
||||
|
||||
IFS=$'\n'; set -f
|
||||
for type in ${TYPE_ARR[@]}; do
|
||||
if [[ "${type}" != "${NEW_TYPE}" ]]; then
|
||||
IMG_ARR+=($(find ${IMG_FOLDER} -name "*${type}"))
|
||||
IMG_ARR_NEW+=($(find ${IMG_FOLDER} -name "*${type}" | sed 's/\.'"${type}"'/.'"${NEW_TYPE}"'/g'))
|
||||
fi
|
||||
done
|
||||
for ((i=0;i<${#IMG_ARR[@]};i++)); do
|
||||
echo "Convert ${IMG_ARR[$i]} ${IMG_ARR_NEW[$i]}"
|
||||
convert -quiet "${IMG_ARR[$i]}" "${IMG_ARR_NEW[$i]}" >> /dev/null
|
||||
rm "${IMG_ARR[$i]}"
|
||||
done
|
||||
}
|
||||
|
||||
get_config() {
|
||||
if [ $# -eq 0 ] || [ ${1:0:1} != - ]; then
|
||||
echo "Too few Arguments"
|
||||
usage
|
||||
exit
|
||||
fi
|
||||
while [[ ${1:0:1} == - ]]; do
|
||||
echo "Param: $1"
|
||||
[[ $1 == -h ]] && {
|
||||
usage
|
||||
exit
|
||||
};
|
||||
[[ $1 == -f ]] && {
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Too few Arguments"
|
||||
usage
|
||||
exit
|
||||
fi
|
||||
file="$2";
|
||||
shift 2;
|
||||
continue;
|
||||
}
|
||||
[[ $1 == -d ]] && {
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Too few Arguments"
|
||||
usage
|
||||
exit
|
||||
fi
|
||||
folder="$2";
|
||||
shift 2;
|
||||
continue;
|
||||
}
|
||||
[[ $1 == -t ]] && {
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Too few Arguments"
|
||||
usage
|
||||
exit
|
||||
fi
|
||||
NEW_TYPE="$2";
|
||||
shift 2;
|
||||
continue;
|
||||
}
|
||||
echo "Parameter $1 discarded"
|
||||
shift 1;
|
||||
done
|
||||
}
|
||||
get_config "$@"
|
||||
if [[ "${file}" != "" ]]; then
|
||||
img_convert "${file}"
|
||||
fi
|
||||
if [[ "${folder}" != "" ]]; then
|
||||
folder_magic "${folder}"
|
||||
fi
|
||||
@@ -1,190 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
logfile=Path(f"media_{time.strftime("%Y%m%d_%H%M%S")}.log")
|
||||
|
||||
def log_message(message):
|
||||
with open(logfile, "a") as log:
|
||||
log.write(f"{message}\n")
|
||||
print(message)
|
||||
|
||||
def get_duration_ms(media: Path) -> int:
|
||||
duration = subprocess.check_output(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(media),
|
||||
],
|
||||
text=True,
|
||||
).strip()
|
||||
|
||||
return int(float(duration) * 1000)
|
||||
|
||||
def ffmpeg_escape(path: Path) -> str:
|
||||
return str(path.resolve()).replace("'", r"'\''")
|
||||
|
||||
def create_m4b(root_path: Path, book_dir: Path) -> None:
|
||||
media_files = sorted(book_dir.glob("*.m[p,4][3,b,a]"))
|
||||
if not media_files:
|
||||
return
|
||||
|
||||
output_file_tmp = root_path / "tmp" / f"{book_dir.name}.m4b"
|
||||
if not output_file_tmp.parent.exists():
|
||||
output_file_tmp.parent.mkdir()
|
||||
output_file_final = book_dir / f"{book_dir.name}.m4b"
|
||||
|
||||
if output_file_final.exists():
|
||||
log_message(f"[SKIP] {output_file_final}")
|
||||
log_message("Book already present")
|
||||
return
|
||||
|
||||
log_message(f"[CREATE] {output_file_final}")
|
||||
|
||||
with (tempfile.TemporaryDirectory() as tmp):
|
||||
tmpdir = Path(tmp)
|
||||
|
||||
concat_file = tmpdir / "concat.txt"
|
||||
metadata_file = tmpdir / "chapters.txt"
|
||||
|
||||
current_ms = 0
|
||||
|
||||
with concat_file.open("w", encoding="utf-8") as cf, \
|
||||
metadata_file.open("w", encoding="utf-8") as mf:
|
||||
mf.write(";FFMETADATA1\n")
|
||||
|
||||
for media in media_files:
|
||||
cf.write(f"file '{ffmpeg_escape(media)}'\n")
|
||||
|
||||
duration_ms = get_duration_ms(media)
|
||||
|
||||
mf.write("[CHAPTER]\n")
|
||||
mf.write("TIMEBASE=1/1000\n")
|
||||
mf.write(f"START={current_ms}\n")
|
||||
mf.write(f"END={current_ms + duration_ms}\n")
|
||||
mf.write(f"title={media.stem}\n")
|
||||
|
||||
current_ms += duration_ms
|
||||
cmd_backup = [ "ffmpeg","-y","-f","concat","-safe","0",
|
||||
"-i",str(concat_file),"-i",str(metadata_file),
|
||||
# cover from first media
|
||||
"-i",str(media_files[0]),
|
||||
# audio from concat
|
||||
"-map","0:a",
|
||||
# cover image from first media
|
||||
"-map","2:v?",
|
||||
"-map_metadata","1",
|
||||
|
||||
"-c:a","copy",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
# copy cover image without re-encoding
|
||||
"-c:v","copy","-disposition:v:0","attached_pic",
|
||||
str(output_file_tmp),
|
||||
]
|
||||
cmd_default = ["ffmpeg", "-y", "-f", "concat", "-safe", "0",
|
||||
"-i", str(concat_file), "-i", str(metadata_file),
|
||||
# cover from first media
|
||||
"-i", str(media_files[0]),
|
||||
# audio from concat
|
||||
"-map", "0:a",
|
||||
# cover image from first media
|
||||
"-map", "2:v?",
|
||||
"-map_metadata", "1",
|
||||
|
||||
"-c:a", "copy",
|
||||
# copy cover image without re-encoding
|
||||
"-c:v", "copy", "-disposition:v:0", "attached_pic",
|
||||
str(output_file_tmp),
|
||||
]
|
||||
with logfile.open("a", encoding="utf-8") as log:
|
||||
result = subprocess.run(
|
||||
cmd_default,
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 234:
|
||||
log_message(f"[ERROR] {result.returncode}")
|
||||
result = subprocess.run(
|
||||
cmd_backup,
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
log_message("Success, delete media files")
|
||||
output_file_tmp.rename(output_file_final)
|
||||
#for media in media_files:
|
||||
# media.unlink()
|
||||
else:
|
||||
log_message(f"FAILED: Code: {result.returncode}, Stderr: {result.stderr}")
|
||||
|
||||
def find_book_dirs(root: Path) -> list[Path]:
|
||||
"""
|
||||
A book directory is any directory containing more than one
|
||||
audiobook file (.mp3, .m4a, .m4b)
|
||||
"""
|
||||
|
||||
media_files = (
|
||||
list(root.rglob("*.mp3"))
|
||||
+ list(root.rglob("*.m4a"))
|
||||
+ list(root.rglob("*.m4b"))
|
||||
)
|
||||
|
||||
counts: dict[Path, int] = {}
|
||||
|
||||
for file in media_files:
|
||||
counts[file.parent] = counts.get(file.parent, 0) + 1
|
||||
|
||||
return sorted(
|
||||
directory
|
||||
for directory, count in counts.items()
|
||||
if count > 1
|
||||
)
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"path",
|
||||
type=Path,
|
||||
help="Book folder or root folder",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
target = args.path.resolve()
|
||||
|
||||
if not target.exists():
|
||||
raise SystemExit(f"Path does not exist: {target}")
|
||||
|
||||
# Direct book folder
|
||||
if any(target.glob("*.m[p,4][3,b]")):
|
||||
create_m4b(target.parent,target)
|
||||
return
|
||||
|
||||
book_dir_set = find_book_dirs(target)
|
||||
|
||||
for book_dir in book_dir_set:
|
||||
log_message(f"Found Media: {book_dir}")
|
||||
# Root folder containing many books
|
||||
for book_dir in book_dir_set:
|
||||
try:
|
||||
create_m4b(target,book_dir)
|
||||
except Exception as exc:
|
||||
log_message(f"[ERROR] {book_dir}: {exc}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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()
|
||||
@@ -167,19 +167,22 @@ process_file() {
|
||||
|
||||
|
||||
HOSTNAME_EXPECTED="mediahost"
|
||||
SOURCE_DIR="/data/nextcloud/syncuser/alina/Dropfolder"
|
||||
TARGET_DIR="/data/nextcloud/syncuser/camera"
|
||||
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
|
||||
syncNC -d -c
|
||||
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 )
|
||||
|
||||
@@ -202,7 +205,7 @@ if [[ "$(hostname)" == "$HOSTNAME_EXPECTED" ]]; then
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
syncNC -d -c
|
||||
echo Sync Nextcloud
|
||||
syncNC -c
|
||||
fi
|
||||
|
||||
Regular → Executable
+1
-47
@@ -7,7 +7,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from serverlibrary import get_audio_tracks
|
||||
|
||||
def check_command(command):
|
||||
if shutil.which(command) is None:
|
||||
@@ -60,52 +60,6 @@ def scan_dvd(dvd_name: Path):
|
||||
|
||||
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.
|
||||
Executable
+2287
File diff suppressed because it is too large.
Load diff
+309
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from serverlibrary import words, word_similarity, score_match_jaccard, score_match_fuzzy, combined_score
|
||||
|
||||
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 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, weight_fuzzy, weight_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, weight_fuzzy, weight_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
+384
@@ -0,0 +1,384 @@
|
||||
#!/home/lucas/scripts/.venv/bin/python
|
||||
|
||||
import re
|
||||
import requests
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
weight_jaccard = 0.6
|
||||
weight_fuzzy = 1 - weight_jaccard
|
||||
|
||||
from serverlibrary import *
|
||||
|
||||
logging.setLoggerClass(CustomLogger)
|
||||
logger: CustomLogger = logging.getLogger(__name__) # type: ignore[assignment]
|
||||
|
||||
|
||||
def find_tmdbid_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, get_all: bool = False):
|
||||
# _, 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 not get_all and (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
|
||||
|
||||
logger.info(f"Type : {media_type}")
|
||||
logger.info(f"TMDB ID : {data.get('id')}")
|
||||
logger.info(f"Title : {data.get('name') or data.get('title')}")
|
||||
logger.info(
|
||||
f"Original : {data.get('original_name') or data.get('original_title')}"
|
||||
)
|
||||
logger.info(
|
||||
f"Year : {(data.get('first_air_date') or data.get('release_date') or '')[:4]}"
|
||||
)
|
||||
logger.info(f"Rating : {data.get('vote_average')}")
|
||||
logger.info(f"Votes : {data.get('vote_count')}")
|
||||
logger.info(f"Status : {data.get('status')}")
|
||||
logger.info(f"Tagline : {data.get('tagline')}")
|
||||
logger.info(f"Overview : {data.get('overview')}")
|
||||
|
||||
genres = ", ".join(g["name"] for g in data.get("genres", []))
|
||||
logger.info(f"Genres : {genres}")
|
||||
|
||||
logger.info(
|
||||
f"Poster : https://image.tmdb.org/t/p/original{data.get('poster_path', '')}"
|
||||
)
|
||||
logger.info(
|
||||
f"Backdrop : https://image.tmdb.org/t/p/original{data.get('backdrop_path', '')}"
|
||||
)
|
||||
|
||||
if media_type == "tv":
|
||||
logger.info(f"Seasons : {data.get('number_of_seasons')}")
|
||||
logger.info(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 episode in episodes:
|
||||
score_fuzzy = score_match_fuzzy(
|
||||
filename_norm, normalize(episode["title"], title, year)
|
||||
)
|
||||
score_jaccard = score_match_jaccard(
|
||||
filename_norm, normalize(episode["title"], title, year)
|
||||
)
|
||||
score = combined_score(score_fuzzy, score_jaccard, weight_fuzzy, weight_jaccard)
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = episode
|
||||
|
||||
return best, best_score
|
||||
|
||||
|
||||
def rename_media(episodes, series_path, file_title, tmdb_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
|
||||
logger.error(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:
|
||||
logger.warning(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:
|
||||
logger.warning(f"S{season:02d}E{episode:02d} not found")
|
||||
continue
|
||||
prefix = episode_season_number_match.group(1).replace(
|
||||
file_title, tmdb_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}"
|
||||
)
|
||||
|
||||
logger.info(f"{file.name} -> {new_name}")
|
||||
|
||||
file_list.append(file)
|
||||
new_file_list.append(new_name)
|
||||
|
||||
logger.info(f"{series} -> {tmdb_title}")
|
||||
|
||||
file_list.append(series_path)
|
||||
new_file_list.append(tmdb_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:
|
||||
logger.info("No changes necessary.")
|
||||
return
|
||||
answer = input("Rename files? [y/N]: ").strip().lower()
|
||||
if answer in ("y", "yes"):
|
||||
logger.info("Proceeding...")
|
||||
for file, new_file in zip(file_list, new_file_list):
|
||||
file.rename(file.with_name(new_file))
|
||||
logger.info("All files renamed")
|
||||
return
|
||||
logger.warning("Cancelled.")
|
||||
return
|
||||
|
||||
|
||||
def process_series(series_path: Path, skip_rename: bool = False):
|
||||
series = series_path.name
|
||||
|
||||
m = re.match(r"^(.*?)\s*\((\d{4})\)\s*\[(\d+)\]$", series)
|
||||
|
||||
if not m:
|
||||
logger.warning(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:
|
||||
logger.error("TMDB ID required")
|
||||
return
|
||||
|
||||
try:
|
||||
data = find_by_tmdb_id(file_tmdb_id)
|
||||
|
||||
if not data:
|
||||
logger.error("TMDB lookup failed")
|
||||
return
|
||||
|
||||
media_type, media_data = data
|
||||
|
||||
tmdb_title = f"{(media_data.get('name') or media_data.get('title'))}"
|
||||
tmdb_year = f"{(media_data.get('first_air_date') or media_data.get('release_date') or '')[:4]}"
|
||||
new_series_folder_name = f"{tmdb_title} ({tmdb_year}) [{media_data.get('id')}]"
|
||||
|
||||
# print_results(data)
|
||||
get_all = False
|
||||
extra_episodes_path = series_path / "Extra Episodes"
|
||||
if extra_episodes_path.is_dir():
|
||||
get_all = True
|
||||
|
||||
episodes = get_episode_list(media_data, series_path, get_all=get_all)
|
||||
|
||||
process_extra_episodes(episodes, extra_episodes_path, tmdb_title, tmdb_year)
|
||||
|
||||
if skip_rename:
|
||||
return
|
||||
|
||||
rename_media(episodes, series_path, file_title, new_series_folder_name)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing {series}: {e}")
|
||||
|
||||
|
||||
def process_extra_episodes(
|
||||
episode_list: list, extra_episodes_path: Path, tmdb_title, tmdb_year
|
||||
):
|
||||
for file in extra_episodes_path.iterdir():
|
||||
if not (file.suffix == ".mkv" or file.suffix == ".mp4"):
|
||||
continue
|
||||
|
||||
best_matc, best_score = best_episode_match(
|
||||
file, episode_list, tmdb_title, tmdb_year
|
||||
)
|
||||
|
||||
|
||||
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',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-ll",
|
||||
"--log-level",
|
||||
type=str.upper,
|
||||
default="INFO",
|
||||
choices=["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
help="Set log level",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
setup_logging(args.log_level, "sonar_translation")
|
||||
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
+770
@@ -0,0 +1,770 @@
|
||||
#!/home/lucas/scripts/.venv/bin/python3
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import filecmp
|
||||
import requests
|
||||
|
||||
weight_jaccard = 0.5
|
||||
weight_fuzzy = 1 - weight_jaccard
|
||||
MATCH_THRESHOLD = 0.85
|
||||
|
||||
from serverlibrary import (
|
||||
VIDEO_SUFFIXES,
|
||||
CustomLogger,
|
||||
combined_score,
|
||||
safe_rename,
|
||||
score_match_fuzzy,
|
||||
score_match_jaccard,
|
||||
score_match_recall_jaccard,
|
||||
setup_logging,
|
||||
tmdb_get,
|
||||
tvdb_get,
|
||||
words,
|
||||
)
|
||||
|
||||
logging.setLoggerClass(CustomLogger)
|
||||
logger: CustomLogger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize(
|
||||
text: str, title: str | None = None, year: int | str | None = None
|
||||
) -> str:
|
||||
text = text.lower()
|
||||
# remove series/movie title
|
||||
if title:
|
||||
text = re.sub(re.escape(title.lower()), " ", text)
|
||||
# remove release year
|
||||
if year:
|
||||
text = re.sub(rf"\b{re.escape(str(year))}\b", " ", text)
|
||||
# remove file extension
|
||||
text = re.sub(r"\.[a-z0-9]{2,4}$", " ", text)
|
||||
# remove text inside square brackets
|
||||
text = re.sub(r"\[[^\]]*\]", " ", text)
|
||||
# remove episode numbers (S01E01)
|
||||
text = re.sub(r"\bs\d+e\d+\b", " ", text, flags=re.IGNORECASE)
|
||||
# remove common release tags
|
||||
text = re.sub(
|
||||
r"\b(?:1080p|720p|2160p|x264|x265|h264|h265|bluray|web[- ]?dl|webrip|dvdrip)\b",
|
||||
" ",
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
# normalize separators
|
||||
text = re.sub(r"[._-]", " ", text)
|
||||
# German normalization
|
||||
text = text.replace("ß", "ss")
|
||||
# "(123 456)" -> "(123)"
|
||||
text = re.sub(r"\((\d+)\s+\d+\)", r"(\1)", text)
|
||||
# "(123___456)" -> "(123)"
|
||||
text = re.sub(r"\((\d+)_+\d+\)", r"(\1)", text)
|
||||
# remove remaining punctuation
|
||||
text = re.sub(r"[^\w\s]", " ", text)
|
||||
# collapse whitespace
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def files_equal(file1: Path, file2: Path) -> bool:
|
||||
if filecmp.cmp(file1, file2, shallow=False):
|
||||
return True
|
||||
logger.debug(f"Files {file1.name} and {file2.name} are not equal")
|
||||
return False
|
||||
|
||||
|
||||
def parse_series_name(series_name: str) -> tuple[str, str, str]:
|
||||
"""
|
||||
Universe (1987) [66757]
|
||||
-> ("Universe", 1987, 66757)
|
||||
Universe (1987)
|
||||
-> ("Universe", 1987, None)
|
||||
Universe
|
||||
-> ("Universe", None, None)
|
||||
"""
|
||||
|
||||
tmdb_id = ""
|
||||
year = ""
|
||||
|
||||
id_match = re.search(r"\[(\d+)]", series_name)
|
||||
if id_match:
|
||||
tmdb_id = id_match.group(1)
|
||||
|
||||
year_match = re.search(r"\((\d{4})\)", series_name)
|
||||
if year_match:
|
||||
year = year_match.group(1)
|
||||
|
||||
title = re.sub(r"\[\d+]", "", series_name)
|
||||
title = re.sub(r"\(\d{4}\)", "", title)
|
||||
title = title.strip()
|
||||
|
||||
return title, year, tmdb_id
|
||||
|
||||
|
||||
def find_by_tvdb_id(tvdb_id: str, language="deu"):
|
||||
"""
|
||||
Try to determine whether a TMDB ID is a TV show or movie.
|
||||
|
||||
Returns:
|
||||
("tv", data)
|
||||
("movie", data)
|
||||
(None, None)
|
||||
"""
|
||||
|
||||
return tvdb_get(
|
||||
f"/series/{tvdb_id}/extended",
|
||||
{"language": language},
|
||||
)["data"]
|
||||
|
||||
|
||||
def get_tmdb_last_updated(tmdb_id: str) -> str:
|
||||
latest = None
|
||||
data = tmdb_get(f"/tv/{tmdb_id}/changes")
|
||||
for change in data["changes"]:
|
||||
for item in change["items"]:
|
||||
ts = datetime.strptime(
|
||||
item["time"],
|
||||
"%Y-%m-%d %H:%M:%S UTC",
|
||||
)
|
||||
|
||||
if latest is None or ts > latest:
|
||||
latest = ts
|
||||
if latest is None:
|
||||
logger.debug("Use default lastChange of 1 week")
|
||||
latest = datetime.now() - timedelta(days=7)
|
||||
return latest.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def find_by_tmdb_id(tmdb_id: str, language="de-DE", type="tv"):
|
||||
"""
|
||||
Try to determine whether a TMDB ID is a TV show or movie.
|
||||
|
||||
Returns:
|
||||
("tv", data)
|
||||
("movie", data)
|
||||
(None, None)
|
||||
"""
|
||||
logger.trace(f"Find entry to id {tmdb_id}")
|
||||
result = tmdb_get(
|
||||
f"/{type}/{tmdb_id}",
|
||||
{
|
||||
"language": language,
|
||||
},
|
||||
)
|
||||
|
||||
result["type"] = type
|
||||
result["year"] = (
|
||||
f"{(result.get('first_air_date') or result.get('release_date') or '')[:4]}"
|
||||
)
|
||||
result["tmdb_id"] = result["id"]
|
||||
result["tvdb_id"] = None
|
||||
result["origin_country"] = result["origin_country"][0]
|
||||
result["name"] = result.get("name") or result.get("title")
|
||||
return result
|
||||
|
||||
|
||||
def get_all_episodes_by_tvdbid(tvdb_id: str, language: str = "deu"):
|
||||
series_data = find_by_tvdb_id(tvdb_id)
|
||||
|
||||
episodes_data = []
|
||||
|
||||
for season in series_data.get("seasons", []):
|
||||
logger.info(f"Requesting Season: {season.get('number')}")
|
||||
season_data = {}
|
||||
try:
|
||||
season_data = tvdb_get(
|
||||
f"/seasons/{season['id']}/extended",
|
||||
{"language": language},
|
||||
)["data"]
|
||||
except requests.exceptions.RequestException:
|
||||
logger.error(
|
||||
"Failed to fetch season %s",
|
||||
season["id"],
|
||||
)
|
||||
for episode in season_data.get("episodes", []):
|
||||
if language in episode["nameTranslations"] and (
|
||||
not episode["name"] or series_data["originalLanguage"] != language
|
||||
):
|
||||
episode["name"] = tvdb_get(
|
||||
f"/episodes/{episode['id']}/translations/{language}"
|
||||
)["data"]["name"]
|
||||
episodes_data.append(
|
||||
{
|
||||
"id": episode["id"],
|
||||
"season": episode["seasonNumber"],
|
||||
"episode": episode["number"],
|
||||
"name": episode["name"],
|
||||
}
|
||||
)
|
||||
|
||||
series_data["number_of_seasons"] = len(series_data.get("seasons"))
|
||||
series_data["number_of_episodes"] = len(episodes_data)
|
||||
network = series_data.get("originalNetwork") or series_data.get("latestNetwork")
|
||||
if isinstance(network, dict):
|
||||
network = network.get("name")
|
||||
series_data["network"] = network
|
||||
return series_data, episodes_data
|
||||
|
||||
|
||||
def get_all_episodes_by_tmdbid(tmdb_id, language: str = "de-DE"):
|
||||
def get_season(tmdb_id, season_number):
|
||||
return tmdb_get(
|
||||
f"/tv/{tmdb_id}/season/{season_number}",
|
||||
{
|
||||
"language": language,
|
||||
},
|
||||
)
|
||||
|
||||
series_data = find_by_tmdb_id(tmdb_id)
|
||||
episodes_data = []
|
||||
|
||||
for season in series_data.get("seasons", []):
|
||||
season_number = season["season_number"]
|
||||
season_data = get_season(series_data.get("id"), season_number)
|
||||
|
||||
for episode in season_data.get("episodes", []):
|
||||
episodes_data.append(
|
||||
{
|
||||
"id": episode["id"],
|
||||
"season": season_number,
|
||||
"episode": episode["episode_number"],
|
||||
"name": episode["name"],
|
||||
}
|
||||
)
|
||||
|
||||
network = series_data["networks"]
|
||||
if isinstance(network, list):
|
||||
network = network[0].get("name")
|
||||
series_data["network"] = network
|
||||
return series_data, episodes_data
|
||||
|
||||
|
||||
def get_tvdbid_by_tmdb(series_path: Path, language: str = "deu"):
|
||||
title, year, tmdb_id = parse_series_name(series_path.name)
|
||||
|
||||
if tmdb_id:
|
||||
results = tvdb_get(
|
||||
f"/search/remoteid/{tmdb_id}",
|
||||
{"language": language},
|
||||
)["data"]
|
||||
else:
|
||||
return get_tvdbid_by_name(series_path)
|
||||
result = None
|
||||
for res in results:
|
||||
if "series" not in res:
|
||||
continue
|
||||
if normalize(title) in res["series"].get("name", "").casefold() and str(
|
||||
res["series"].get("year", "")
|
||||
) == str(year):
|
||||
result = res["series"]
|
||||
logger.info(result)
|
||||
if not result:
|
||||
logger.error(f"Results: {results}")
|
||||
raise Exception(f"Couldnt find results for Series {title} ")
|
||||
result["tvdb_id"] = result["id"]
|
||||
result["type"] = "tv"
|
||||
|
||||
if result.get("translations"):
|
||||
result["original_name"] = result.get("translations").get(
|
||||
result.get("primary_language")
|
||||
)
|
||||
|
||||
result["tmdb_id"] = tmdb_id
|
||||
result["origin_country"] = result.get("originalCountry")
|
||||
status = result.get("status")
|
||||
result["name"] = result.get("name") or result.get("title")
|
||||
if isinstance(status, dict):
|
||||
result["status"] = status.get("name")
|
||||
return result
|
||||
|
||||
|
||||
def databes_entry_is_newer(last_updated: str, file: Path) -> bool:
|
||||
tvdb_time = datetime.strptime(
|
||||
last_updated,
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
file_time = datetime.fromtimestamp(file.stat().st_mtime)
|
||||
if tvdb_time > file_time:
|
||||
logger.debug(
|
||||
f"Database entry is newer than {file.name}: {file_time} > {tvdb_time}"
|
||||
)
|
||||
return tvdb_time > file_time
|
||||
|
||||
|
||||
def get_tvdbid_by_name(series_path: Path, language: str = "deu"):
|
||||
title, year, tmdb_id = parse_series_name(series_path.name)
|
||||
|
||||
logger.trace(f"Find series {title} by tvdb name search")
|
||||
params = {
|
||||
"query": title,
|
||||
"type": "series",
|
||||
"language": language,
|
||||
}
|
||||
|
||||
if year:
|
||||
params["year"] = str(year)
|
||||
|
||||
results = tvdb_get(
|
||||
"/search",
|
||||
params,
|
||||
)["data"]
|
||||
|
||||
if not results:
|
||||
raise RuntimeError(f"Series not found: {series_path}")
|
||||
|
||||
# Filter by year if available
|
||||
filtered = []
|
||||
|
||||
for result in results:
|
||||
result_tmdb_id = result.get("remote_ids")
|
||||
result["tmdb_id"] = None
|
||||
if not result_tmdb_id:
|
||||
continue
|
||||
for id in result_tmdb_id:
|
||||
if id.get("sourceName") == "TheMovieDB.com":
|
||||
result["tmdb_id"] = id.get("id")
|
||||
if tmdb_id and result["tmdb_id"] and result["tmdb_id"] == tmdb_id:
|
||||
filtered.append(result)
|
||||
|
||||
if filtered:
|
||||
results = filtered
|
||||
|
||||
result = results[0]
|
||||
result["lastUpdated"] = tvdb_get(f"/series/{result['tvdb_id']}")["data"][
|
||||
"lastUpdated"
|
||||
]
|
||||
result["id"] = result["tvdb_id"]
|
||||
result["type"] = "tv"
|
||||
|
||||
if result.get("translations"):
|
||||
result["original_name"] = result.get("translations").get(
|
||||
result.get("primary_language")
|
||||
)
|
||||
result["origin_country"] = ""
|
||||
if result.get("country"):
|
||||
result["origin_country"] = result.get("country")[0]
|
||||
|
||||
status = result.get("status")
|
||||
result["name"] = result.get("name") or result.get("title")
|
||||
if isinstance(status, dict):
|
||||
result["status"] = status.get("name")
|
||||
|
||||
if result["primary_language"] != language:
|
||||
result["name"] = result["translations"][language]
|
||||
result["overview"] = result["overviews"][language]
|
||||
return result
|
||||
|
||||
|
||||
def get_tmdbid_by_name(series_path: Path, type="tv", language="de-DE"):
|
||||
"""
|
||||
Search TMDB by title.
|
||||
|
||||
media_type:
|
||||
tv | movie
|
||||
"""
|
||||
title, year, _ = parse_series_name(series_path.name)
|
||||
logger.trace(f"Find series {title} by tmdb name search")
|
||||
params = {
|
||||
"query": title,
|
||||
"language": "en-EN",
|
||||
}
|
||||
if year:
|
||||
if type == "tv":
|
||||
params["first_air_date_year"] = year
|
||||
else:
|
||||
params["year"] = year
|
||||
results = tmdb_get(
|
||||
f"/search/{type}",
|
||||
params,
|
||||
)
|
||||
if not results:
|
||||
raise RuntimeError(f"Series not found: {series_path}")
|
||||
result = results["results"][0]
|
||||
result["type"] = type
|
||||
result["year"] = (
|
||||
f"{(result.get('first_air_date') or result.get('release_date') or '')[:4]}"
|
||||
)
|
||||
result["tvdb_id"] = None
|
||||
result["origin_country"] = result["origin_country"][0]
|
||||
result["name"] = result.get("name") or result.get("title")
|
||||
return result
|
||||
|
||||
|
||||
def print_results(
|
||||
media_data: dict, series_data: dict, episodes: list, extended: bool = False
|
||||
):
|
||||
|
||||
logger.info(f"TVDB ID : {media_data.get('tvdb_id')}")
|
||||
logger.info(f"TMDB ID : {media_data.get('tmdb_id')}")
|
||||
logger.info(f"Title {media_data.get('name')}")
|
||||
|
||||
logger.info(
|
||||
f"Original : {media_data.get('original_name') or media_data.get('original_title')}"
|
||||
)
|
||||
|
||||
logger.info(f"Country : {media_data['origin_country']}")
|
||||
logger.info(f"Network : {series_data['network']}")
|
||||
if extended:
|
||||
logger.info(f"Year : {media_data['year']}")
|
||||
logger.info(f"Rating : {media_data.get('vote_average')}")
|
||||
logger.info(f"Votes : {media_data.get('vote_count')}")
|
||||
logger.info(f"Status : {media_data.get('status')}")
|
||||
logger.info(f"Overview : {media_data.get('overview')}")
|
||||
genres = ", ".join(g["name"] for g in series_data.get("genres", []))
|
||||
logger.info(f"Genres : {genres}")
|
||||
|
||||
logger.info(
|
||||
f"Poster : https://image.tmdb.org/t/p/original{media_data.get('poster_path', '')}"
|
||||
)
|
||||
logger.info(
|
||||
f"Backdrop : https://image.tmdb.org/t/p/original{media_data.get('backdrop_path', '')}"
|
||||
)
|
||||
|
||||
if media_data.get("type") == "tv":
|
||||
logger.info(f"Seasons : {series_data.get('number_of_seasons')}")
|
||||
logger.info(f"Episodes : {series_data.get('number_of_episodes')}")
|
||||
|
||||
|
||||
def best_episode_title_match(filename, episodes):
|
||||
search_words = words(normalize(filename))
|
||||
logger.debug(f"Search Words: {search_words}")
|
||||
best_score = 0.0
|
||||
best_episode = None
|
||||
|
||||
for episode in episodes:
|
||||
if not episode["name"] or episode["name"] == "Unkown":
|
||||
continue
|
||||
candidate_words = words(normalize(episode["name"]))
|
||||
fuzzy = score_match_fuzzy(
|
||||
search_words,
|
||||
candidate_words,
|
||||
)
|
||||
jaccard = score_match_jaccard(
|
||||
search_words,
|
||||
candidate_words,
|
||||
)
|
||||
recall_jaccard1 = score_match_recall_jaccard(
|
||||
search_words,
|
||||
candidate_words,
|
||||
)
|
||||
recall_jaccard2 = score_match_recall_jaccard(
|
||||
candidate_words,
|
||||
search_words,
|
||||
)
|
||||
|
||||
score = combined_score(
|
||||
fuzzy,
|
||||
jaccard,
|
||||
weight_fuzzy=weight_fuzzy,
|
||||
weight_jaccard=weight_jaccard,
|
||||
)
|
||||
score = max(score, recall_jaccard1, recall_jaccard2)
|
||||
|
||||
# if "hyänen" in search_words and score > 0.6:
|
||||
# logger.warning(
|
||||
# f"SEARCH WORDS: {search_words}, CANDIDATE WORDS: {candidate_words}"
|
||||
# )
|
||||
|
||||
if score > best_score:
|
||||
logger.trace(f"{search_words}:\t\t{candidate_words},\t\t{score}")
|
||||
best_score = score
|
||||
best_episode = episode
|
||||
|
||||
return best_episode, best_score
|
||||
|
||||
|
||||
def translate_series_folder(episodes, series_path, media_data: dict, args):
|
||||
series = series_path.name
|
||||
logger.info(f"Execute translate_series for {media_data['name']}")
|
||||
episode_map = {(ep["season"], ep["episode"]): ep for ep in episodes}
|
||||
file_list = []
|
||||
new_file_list = []
|
||||
for file in series_path.rglob("*"):
|
||||
if (
|
||||
not file.is_file()
|
||||
or file.suffix.lower() not in VIDEO_SUFFIXES
|
||||
or "Season" not in file.parent.name
|
||||
):
|
||||
continue
|
||||
logger.trace(f"Translate file: {file.name}")
|
||||
match_SE_name = re.search(r"S(\d{2}|\d{4})E(\d{2,3})", file.name, re.IGNORECASE)
|
||||
if not match_SE_name:
|
||||
logger.error(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:
|
||||
logger.warning(f"Episode not found: S{season:02d}E{episode:02d}")
|
||||
continue
|
||||
episode_season_number_match = re.match(
|
||||
r"^(.*?S(\d{2}|\d{4})E\d{2,3})\s*-\s*.*?\s*((?:\[[^\]]+\])+)?$",
|
||||
file.stem,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if not episode_season_number_match:
|
||||
logger.warning(f"S{season:02d}E{episode:02d} not found")
|
||||
continue
|
||||
prefix = f"{media_data['name']} ({media_data['year']}) - S{season:02d}E{episode:02d}" # Andor (2022) - S01E07
|
||||
quality = (
|
||||
episode_season_number_match.group(3) or ""
|
||||
) # [WEBRip-1080p][AAC 5.1][h265]
|
||||
|
||||
title = episode_number["name"].replace("/", "_")
|
||||
if title == "_":
|
||||
title = "slash"
|
||||
new_name = (
|
||||
f"{prefix} - {title}{(' ' + quality) if quality else ''}{file.suffix}"
|
||||
)
|
||||
logger.trace(f"Prefix: {prefix}, Title: {title}, Quality: {quality}")
|
||||
logger.trace(f"{file.name} -> {new_name}")
|
||||
|
||||
file_list.append(file)
|
||||
new_file_list.append(new_name)
|
||||
|
||||
new_folder_name = (
|
||||
f"{media_data['name']} ({media_data['year']}) [{media_data['tmdb_id']}]"
|
||||
)
|
||||
if args.translate_series_folder:
|
||||
logger.trace(f"{series} -> {new_folder_name}")
|
||||
file_list.append(series_path)
|
||||
new_file_list.append(new_folder_name)
|
||||
else:
|
||||
logger.info(f"Dont translate Series folder: {series}")
|
||||
|
||||
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:
|
||||
logger.info("No changes necessary.")
|
||||
return
|
||||
|
||||
for file, new_file in zip(file_list, new_file_list):
|
||||
if args.dry_run:
|
||||
logger.debug(f"{file.name} -> {new_file}")
|
||||
continue
|
||||
safe_rename(file, new_file, logger=logger)
|
||||
logger.info("All files renamed")
|
||||
return
|
||||
|
||||
|
||||
def reference_extra_episodes(episodes, series_path: Path, media_data: dict, args):
|
||||
logger.info(f"Execute reference_extra_episodes for {media_data['name']}")
|
||||
extra_episodes_path = series_path / "Extra Episodes"
|
||||
for file in extra_episodes_path.glob("*"):
|
||||
if not file.is_file() or file.suffix.lower() not in VIDEO_SUFFIXES:
|
||||
continue
|
||||
logger.trace(f"Reference file: {file.name}")
|
||||
filename = re.sub(media_data["name"], "", file.stem)
|
||||
episode, score = best_episode_title_match(
|
||||
filename,
|
||||
episodes,
|
||||
)
|
||||
if episode is None:
|
||||
continue
|
||||
|
||||
if score < MATCH_THRESHOLD:
|
||||
logger.warning(
|
||||
f"LOW CONFIDENCE: {filename} {score:.2f}: {normalize(episode.get('name'))}"
|
||||
)
|
||||
continue
|
||||
|
||||
safe_title = re.sub(
|
||||
r'[<>:"/\\|?*]',
|
||||
"",
|
||||
episode["name"],
|
||||
)
|
||||
|
||||
new_name = (
|
||||
f"{media_data['name']}"
|
||||
f" - S{episode['season']:02d}"
|
||||
f"E{episode['episode']:02d}"
|
||||
f" - {safe_title}"
|
||||
f"{file.suffix}"
|
||||
)
|
||||
|
||||
target = file.parent.parent / f"Season {episode['season']:02d}" / new_name
|
||||
if score < 1:
|
||||
logger.debug(f"{file.name} -> {new_name}, score={score:.3f}")
|
||||
elif score == 1:
|
||||
logger.trace(f"{file.name} -> {new_name}, score={score:.3f}")
|
||||
|
||||
if args.dry_run:
|
||||
logger.debug(f"{file.name} -> {target}")
|
||||
continue
|
||||
# logger.info(f"{file.name} -> {target}, score={score:.3f}")
|
||||
safe_rename(file, target, logger=logger)
|
||||
|
||||
|
||||
def get_series_data(series_path: Path, database: str = "tvdb"):
|
||||
series_name, _, tmdb_id = parse_series_name(series_path.name)
|
||||
|
||||
if database == "tvdb":
|
||||
# if tmdb_id:
|
||||
# media_data = get_tvdbid_by_tmdb(series_path)
|
||||
# else:
|
||||
media_data = get_tvdbid_by_name(series_path)
|
||||
elif database == "tmdb":
|
||||
if tmdb_id:
|
||||
media_data = find_by_tmdb_id(tmdb_id)
|
||||
else:
|
||||
media_data = get_tmdbid_by_name(series_path)
|
||||
media_data["lastUpdated"] = get_tmdb_last_updated(tmdb_id)
|
||||
else:
|
||||
raise ValueError(f"Wrong database name: {database}")
|
||||
|
||||
buffer_file = series_path / f"__{series_name}_{database}_media_data.json"
|
||||
with open(
|
||||
buffer_file,
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
json.dump(media_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
if (
|
||||
(series_path / f"{series_name}_{database}_media_data.json").is_file()
|
||||
and files_equal(
|
||||
series_path / f"__{series_name}_{database}_media_data.json",
|
||||
series_path / f"{series_name}_{database}_media_data.json",
|
||||
)
|
||||
and not databes_entry_is_newer(
|
||||
media_data["lastUpdated"],
|
||||
series_path / f"{series_name}_{database}_media_data.json",
|
||||
)
|
||||
):
|
||||
buffer_file.unlink()
|
||||
logger.info("Load from json:")
|
||||
with open(
|
||||
series_path / f"{series_name}_{database}_episodes.json",
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
episodes = json.load(f)
|
||||
with open(
|
||||
series_path / f"{series_name}_{database}_media_data.json",
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
media_data = json.load(f)
|
||||
with open(
|
||||
series_path / f"{series_name}_{database}_series_data.json",
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
series_data = json.load(f)
|
||||
else:
|
||||
if database == "tvdb":
|
||||
logger.info("Query from TVDB")
|
||||
series_data, episodes = get_all_episodes_by_tvdbid(media_data["id"])
|
||||
elif database == "tmdb":
|
||||
logger.info("Query from TMDB")
|
||||
series_data, episodes = get_all_episodes_by_tmdbid(media_data["id"])
|
||||
logger.info("Dump to json")
|
||||
safe_rename(
|
||||
buffer_file,
|
||||
Path(f"{series_name}_{database}_media_data.json"),
|
||||
overwrite=True,
|
||||
logger=logger,
|
||||
)
|
||||
with open(
|
||||
series_path / f"{series_name}_{database}_series_data.json",
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
json.dump(series_data, f, ensure_ascii=False, indent=2)
|
||||
with open(
|
||||
series_path / f"{series_name}_{database}_episodes.json",
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
json.dump(episodes, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return series_data, episodes, media_data
|
||||
|
||||
|
||||
def process_series(series_path: Path, args):
|
||||
series_name = series_path.name
|
||||
logger.debug(f"Pricess series with name : {series_name}")
|
||||
series_data, episodes, media_data = get_series_data(series_path, args.database)
|
||||
logger.info(
|
||||
f"Found series name from TVDB: {media_data['name']}, ID: {media_data['id']}, Year: {media_data.get('year')}"
|
||||
)
|
||||
|
||||
print_results(media_data, series_data, episodes, extended=args.extended)
|
||||
reference_extra_episodes(episodes, series_path, media_data, args)
|
||||
|
||||
translate_series_folder(episodes, series_path, media_data, args)
|
||||
|
||||
|
||||
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',
|
||||
)
|
||||
|
||||
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(
|
||||
"-db",
|
||||
"--database",
|
||||
type=str,
|
||||
default="tvdb",
|
||||
help="Set which database to use for queries. dafault: TvDB",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-lsd--leave-series-dir",
|
||||
dest="translate_series_folder",
|
||||
action="store_false",
|
||||
help="Dont Translate Series Folder",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-e",
|
||||
"--extended",
|
||||
action="store_true",
|
||||
help="Print Extended Infos",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Dry run only",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
setup_logging(args.log_level, "sonarr_translation_tool")
|
||||
|
||||
input_path = Path(args.path).expanduser().resolve()
|
||||
|
||||
if not input_path.is_dir():
|
||||
parser.error(f"Directory not found: {input_path}")
|
||||
|
||||
logger.info(f"Pricess folder: {input_path}")
|
||||
|
||||
# Input is already a series folder
|
||||
if re.match(r"^(.*?)\s*\((\d{4})\)(?:\s*\[(\d*)])?$", input_path.name):
|
||||
logger.info(f"Process series: {input_path.name}")
|
||||
process_series(input_path, args)
|
||||
|
||||
# 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, args)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
Test 1
|
||||
Test 2
|
||||
Test 3
|
||||
Test 4
|
||||
Test 5
|
||||
Test 6
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,7 +3,7 @@ LIST=false
|
||||
RETRIES=""
|
||||
PL_NAME="Default"
|
||||
LOG=1
|
||||
youtubelist="/home/lucas/scripts/youtubelist.txt"
|
||||
youtubelist="/home/lucas/scripts/media_editing/youtubelist.txt"
|
||||
MUSIC=false
|
||||
VIDEO=true
|
||||
DATE="$(date +'%m%d%H%M%S')"
|
||||
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user