Compare commits

...
124 Commits
Author SHA1 Message Date
Lucas Vivo 815cd5a551 feat(audiobook_library): functional write_metadata, create_m4b
shebang python venv
get all metadata tags
kid3 command corrections, add "" to fields
sort self.files
add changed flag to fix_... cmds
fix series numbering
2026-10-29 13:52:44 +01:00
Lucas Vivo bcc927db73 feat(serverlibrary): add logfiles without datetime to setup_loging 2026-10-29 13:49:52 +01:00
lucas 2d97877d00 feat(sonarr_database_tool): working translation and extra_episodes functions
get last update time of tvdb/tmdb
get_tvdb_by_tmdb_id
add the same dict keys for both databases
lookup episodes in german for foreign language media
2026-10-29 09:02:30 +01:00
lucas 73b870b142 chore(pyrightconfig): library defintition for nvim pyright 2026-10-29 09:02:30 +01:00
lucas ad796c174e refactor(serverlibrary): move suffixes to library
move audio/video suffixes to serverlibrary
remove from audiobooks_library, ffmpeg_handling
2026-10-29 09:02:30 +01:00
lucas 18e1fc9325 feat(rotate_logs): add a function to cleanup log dir
trunctuate big files
rotate log groups
remove old files
2026-10-29 08:56:01 +01:00
lucas 2d623bbff6 fix(ffmpeg_handling): remove word boundaries \b from translation function 2026-10-27 19:56:35 +01:00
Lucas Vivo 9929dcbd8c feat(sonarr_database_tool): add tvdb and tmdb fucntions
add functions from tmdb and tvdb script
rename match_series file to sonarr_database_tool
2026-10-27 19:53:41 +01:00
Lucas Vivo 07229a4435 feat(audiobookss_library): add new logger format 2026-10-27 19:49:14 +01:00
lucas 22522549b5 feat(ffmpeg_handling): dictonary expansion, change russ search function
generate search function from translation keys
dont require perfect match for translation
expand vocabulary
2026-10-27 19:45:26 +01:00
lucas 4b4b0fcb5f config(docker_compose): add diun metrics to prometheus and grafana 2026-10-27 19:45:26 +01:00
Lucas Vivo ec44b53a53 chore(git): remove media files .gitignore 2026-10-27 16:40:38 +01:00
lucas 2a48f50717 fix(sonar_translation): add new logging 2026-10-27 16:38:50 +01:00
Lucas Vivo cabf1a21a8 feat(ffmpeg_handling): add only_russian testfile 2026-10-27 13:52:14 +01:00
lucas 298519a446 config(docker compose): add local rundfunkarr-at, unpackerr config, gitea bidy_size fix
add local rundfunkarr from git fork
update unpackerr dafult config(generated at docker compose up)
reverse proxy change: client_max_body_size 10G to transfer larger files
to gitea
2026-10-27 10:00:25 +01:00
Lucas Vivo c4a717a8a8 feat(ffmpeg_handling): remove subs with remove_russian
add remove stream function
change remove audio and remove subs to use this function
fix: show_metadata returns default values and return_tags is given as
parameter
2026-10-27 09:48:06 +01:00
Lucas Vivo 7f2048cda6 faet(serverlibrary): add colorlog to custom logger
import colorlog and change formatter for console
no change needed in client
2026-10-27 09:46:01 +01:00
Lucas Vivo 3f19182d35 fix(ffmpeg_handling): fix return value for remove russian only files 2026-10-27 08:23:09 +01:00
Lucas Vivo b9735e9381 fix(serverlibrary): correct file structure agian
serverlibrary/
├── pyproject.toml
└── src/
└── serverlibrary/
├── __init__.py
└── core.py

add src location to pyproject
2026-10-27 07:58:36 +01:00
lucas 25432d1650 feat(rename_camera_folder): restructuring, add path to subprocess 2026-10-26 18:56:40 +01:00
lucas 2af4724093 fix(match_series_to_tvdb): add new logging from serverlibrary, move files to series folder 2026-10-26 18:55:56 +01:00
lucas 64f2abcdd1 feat(syncNC): waiting function for psgrep nextcloud
wait 3x20s for nextcloudcmds to finish before exiting
2026-10-26 18:55:04 +01:00
lucas a81ab49b91 fix(serverlibrary): fix package structure
structure needs to be:
serverlibrary/
├── pyproject.toml
└── serverlibrary/
    ├── __init__.py
    └── core.py
2026-10-26 18:54:09 +01:00
lucas 51b6b28c91 fix(upgrade.sh): fix local qbittorrent case query 2026-10-26 14:56:30 +01:00
lucas b672696f91 fix(ffmpeg handling): add language tag to is russian identification 2026-10-26 13:00:58 +01:00
Lucas Vivo d43fcd530c feat(ffmpeg handling): add return value for cmd_ function if files need to be moved 2026-10-26 10:22:06 +01:00
lucas 6d229c59f7 fix(ffmpeg handling): add translation, revise logging
add more translations
add setup logging function for file and stdout logger
combine translation functions: translate_audio_tracks,
translate_audio_descriptions
formatting changes
fix move_override function
2026-10-26 09:44:05 +01:00
Lucas Vivo 2d544d317f fix(ffmpeg handling): revise argparser, ffprobe requres string
add add_command function
one shared parent parser

fix(ffmpeg handling): ffprobe requres string not path

fix(ffmpeg handling): unify no output return string
2026-10-25 19:16:27 +01:00
Lucas Vivo 6e9cc10b46 transefer LOG vars to serverlib# 2026-10-25 18:33:03 +01:00
Lucas Vivo 2ea486a8c0 ffmpeg handling: change directory handling for remove russian audio
change directory handling for remove-russian to default
use Path everywhere
transfer LOG vars to serverlibrary
2026-10-25 18:32:51 +01:00
Lucas Vivo 04a8253755 check in test files for ffmpeg 2026-10-25 14:31:50 +01:00
Lucas Vivo b3e9bae3eb ffmpeg handling remove russian subs
remove subtitle removal for russian media
2026-10-25 14:31:20 +01:00
Lucas Vivo 7fbda6b133 metadata audiobook library
delete old scripts
load metadata from file
clean genre
fix artist/comment/composer/series
read books from database
write books to database
2026-10-25 14:30:05 +01:00
lucas c52c83f7ff match series to tvdb: save series/media and season data 2026-10-25 14:24:05 +01:00
lucas b875c1e193 read torrent seed time and ratio from qbittorrent 2026-10-25 14:23:29 +01:00
lucas 7e7a8c3af5 remove old serverhost scripts 2026-10-24 20:11:28 +02:00
lucas 2ec34dd1be add qbittorrent custom image upgrade agian 2026-10-24 20:11:11 +02:00
lucas 5ec0108117 backup system scripts
backup system via restic
backup dav script, remove unneseccary lines
2026-10-24 15:20:23 +02:00
lucas 845446e1fe fix crontab scripts
exclude database files from immich sync
add docker compose file to certbot renew command
2026-10-24 15:20:23 +02:00
lucas 29de1e0f80 qbittorrent private/public separation
revert from qbit Dockerfile to startup script
remove custom qbittorrent from upgrade script
2026-10-24 15:20:23 +02:00
lucas a553e51dd7 crontab backup 2026-10-24 15:20:23 +02:00
lucas cfdadfd9aa fix smartd conf error 2026-10-24 15:20:23 +02:00
lucas a6bd1c2d1a syncNC return value, youtubelist path 2026-10-24 15:20:23 +02:00
lucas 07e24ad97e move generateAlina chart 2026-10-24 15:20:23 +02:00
lucas bbc56a9908 unpackerr, qbittorent separation
add unpackerr docker compose
separate qbittorrent into public/private
gluetun DNS issue w rundfunkarr
2026-10-24 15:20:00 +02:00
lucas 7214cfd52b crontab logging and docker command scripts 2026-10-24 15:20:00 +02:00
lucas 58795e9d5e docker container debug info script 2026-10-24 15:20:00 +02:00
lucas d241017c2a match series to tvdb episodes py
adapt serverlibrary
2026-10-24 15:19:17 +02:00
lucas 104b912404 fix syncNC in py scripts 2026-10-24 15:09:59 +02:00
lucas e4ebc3c5b2 remove rename camera sh and add syncNC to py script 2026-10-24 15:09:59 +02:00
lucas 6e369c3656 add qbittorrent to node_exporter 2026-10-24 15:09:37 +02:00
Lucas Vivo 63c34f5387 script: search series on tvdb and rename episodes with season/episode number 2026-10-24 10:51:21 +02:00
Lucas Vivo fcde06e8b6 add tmdb/tvdb get to serverlibrary 2026-10-24 10:51:21 +02:00
Lucas Vivo f54919554b remove tmdb_get from sonar translation script 2026-10-24 10:51:21 +02:00
Lucas Vivo 93a464f49d remove ffmpeg_handling shell script(deprecated) 2026-10-24 10:51:21 +02:00
Lucas Vivo 437c91dc7a fix install_nvim script 2026-10-24 10:51:21 +02:00
lucas 7f27f187d8 update randomize vpn script to get IP from endpoint 2026-10-23 19:42:39 +02:00
Lucas Vivo 073ddf11fc WallpaperChanger add encryptNT
fix paths
add encryptNT function
fix WC in bash_aliases
2026-10-23 17:03:16 +02:00
Lucas Vivo f5d7962aba remove functions from reference_playlists_to_library.py move to serverlibary 2026-10-23 17:02:12 +02:00
Lucas Vivo 0a6457abe0 set download client for all indexers 2026-10-23 17:01:22 +02:00
Lucas Vivo aa27e1f350 ffmpeg handling: move log-level to general parser 2026-10-19 13:38:01 +02:00
Lucas Vivo 59f1bcb5c1 add scripts dir to python path 2026-10-19 13:37:40 +02:00
Lucas Vivo b1c8365bd8 pyproject toml serverlibrary 2026-10-19 13:25:23 +02:00
Lucas Vivo c5cf55fe21 ffmpeg handling py
proper testing for chapters and metadata
remove all russian finction
2026-10-19 13:24:14 +02:00
lucas 99573fb241 adjust docker number 2026-10-18 16:07:16 +00:00
lucas f9d97de61f upgrade.sh remove docker restart
remove unnecessary docker compose up -d if statement
2026-10-18 16:07:16 +00:00
Lucas Vivo d5c82e364d rename folders 2026-10-17 10:17:13 +02:00
Lucas Vivo c192114bcf create WallpaperCHanger desktop icons 2026-10-17 10:17:13 +02:00
lucas 179256de3e docker backup
mediahost crontab for user lucas
mediarr: set lidarr to develpp
install cleanuparr
install readrr/sonarr/lidarr/prowlarr exporter for prometheus
nextcloud: remive nginx hostname from trusted proxies
wireguard: add wg0 conf
2026-10-17 08:06:23 +00:00
lucas 0e52aefa07 move node exporter and backup docker script 2026-10-17 08:03:23 +00:00
Lucas Vivo 95c2f90031 add parallization to metadata_audiobooks_library.py 2026-10-16 22:30:57 +02:00
Lucas Vivo 1dd80d9a45 WallpaperChanger: private key for decryption 2026-10-16 22:05:47 +02:00
Lucas Vivo dc537635bb write metadata to file on m4b creation 2026-10-16 22:05:09 +02:00
Lucas Vivo be012e334b move and adapt wallpaper changer 2026-10-16 20:14:31 +02:00
Lucas Vivo 35c2104852 add create m4b to audiobook library
add find_by path method
ffmetadata_escape, get_chapters
add per file chapter support for create m4b
2026-10-16 17:44:44 +02:00
Lucas Vivo d1e2f8a3e5 assign raw socket access to etherwake 2026-10-16 17:44:44 +02:00
lucas 2eb7b8d6d1 reference all songs in playlist after Lidarr update 2026-10-16 14:01:30 +00:00
lucas 045861ada6 update docker container number 2026-10-16 14:01:07 +00:00
lucas 8ae894ffb6 add tabstop rules to nvim 2026-10-16 14:00:57 +00:00
lucas 208fbd96b4 move move old scripts 2026-10-16 14:00:42 +00:00
lucas 93b262977b remove old scripts 2026-10-16 13:58:47 +00:00
lucas fd91e7d678 python script to reference media files to playlist 2026-10-15 09:42:59 +00:00
lucas 9a476ef5cd move other dir to os_and_install
asd
2026-10-15 09:42:59 +00:00
lucas dbb8eda1e6 install script for serverhost
asd
2026-10-15 11:41:52 +02:00
lucas 2e273985b7 download ubuntu images and keep them current 2026-10-14 20:51:31 +00:00
lucas f7865f05a1 script to reference moved song in playlists 2026-10-14 20:51:31 +00:00
lucas 719c7068e2 smartd.conf add start stop hostname, qbittorrenthost 2026-10-14 17:43:31 +02:00
lucas 580b667119 node exporter: counter corretion for samsung hdd 2026-10-14 17:42:52 +02:00
lucas 3bf1fc1be6 add exportfs config
ads
2026-10-14 07:31:43 +02:00
lucas 7b0b438740 add nextcloud app update to upgrade script 2026-10-13 20:15:03 +02:00
lucas 2eda7cd33e align convert heic with mediahost and new picture sync
asd
2026-10-13 20:15:03 +02:00
lucas efc150d9ce ssh config and hosts
asd
2026-10-13 20:15:03 +02:00
lucas 044fbd81ad working smartmon node exporter script for sd/nvme 2026-10-12 21:54:40 +02:00
lucas 01ab319eea node exporter: add hostname dependent docker count and default value for smartd script 2026-10-12 19:19:15 +00:00
lucas d5e627325d add quiethost to smartd 2026-10-12 20:49:53 +02:00
lucas deaff35bc3 remove raspberry from bash_aliases
add mediahost to wol.sh
2026-10-12 20:40:34 +02:00
lucas 27d570fc5c node exporter script for smartd 2026-10-12 18:39:53 +00:00
lucas d5fb01adb2 docker backup serverhost
add cadvisor to serverhost
change node-exporter to network host to see propper netw traffic
refine torrentscomplete script
2026-10-12 20:10:54 +02:00
lucas 52c02beb3d mediahost docker backup
add gite to shared proxy
add usb dev and NET privilige to HA
add immich image server
remove sonarr-en
add jackett indexer for ABB
add gluetun-proxy for general proxy
add blackbox exporter to check https sites
set node-exporter to network host to see interface traffic
decrease resolution of cadvisor to save RAM
add status and code SANs for nginx
add upstream block so sites are not redirecte to cloud...
add homeassistant config
add nginx exporter for prometheus
2026-10-12 18:10:41 +00:00
lucas 3f14f53184 add serverhost to smartd.conf 2026-10-12 17:59:00 +00:00
lucas c41bf36afb add smartd conf 2026-10-12 17:50:54 +00:00
lucas 07bab58ae0 add smartctl service 2026-10-12 15:01:20 +00:00
Lucas Vivo d06cff4cbd compare playlists m3u und navidrome 2026-10-12 16:33:53 +02:00
Lucas Vivo 09343a7523 first version of general audiobook library.py 2026-10-12 16:33:53 +02:00
Lucas Vivo dc0cd02e58 combine audiobooks: remove unecessary function 2026-10-12 16:33:53 +02:00
lucas 7158dd4960 change mqtt broker user to new mediahost impl 2026-10-12 12:07:28 +02:00
lucas 4c299c5a38 move sudo for upgrades into script and call script as user 2026-10-12 10:06:37 +00:00
Lucas Vivo 2f3a763054 adapt nextloud scan to docker 2026-10-09 17:36:49 +02:00
Lucas Vivo f7ff789d81 translate edit genre metadata to python
ads

asd
2026-10-08 16:25:50 +02:00
Lucas Vivo 7c6a121504 finalize metadata edit_database
asd
2026-10-08 16:25:50 +02:00
lucas 144d974731 add log to audiobooks edit database
asd
2026-10-08 14:06:41 +02:00
lucas 9e4901699d move metadate 2026-10-08 12:06:09 +00:00
lucas 8c29731ddc update sed in remove_genre 2026-10-08 12:05:53 +00:00
lucas 80ad918686 add log to audiobooks edit database 2026-10-08 10:38:54 +00:00
Lucas Vivo 3d585d14e0 audiobook edit sqlite database to remove audiobook genre 2026-10-08 12:26:35 +02:00
Lucas Vivo 8b6fc7fdcd combine audiobooks: extend to opus
asd

asd
2026-10-08 12:26:08 +02:00
Lucas Vivo e4f1cce494 combine audiobook better logging 2026-10-07 18:59:18 +02:00
Lucas Vivo 8cccf674cf parallelize creat media function call 2026-10-07 18:36:39 +02:00
Lucas Vivo 86679e4e18 combine audiobooks: move temp dir 2026-10-07 14:58:16 +02:00
Lucas Vivo fc9a2dcf0e combine audiobooks: create audiobook in temp dir
add coloured skip message
2026-10-07 14:16:41 +02:00
Lucas Vivo 57520eaa04 update log print colours 2026-10-07 12:01:20 +02:00
Lucas Vivo f9ee0a0694 add percentage counter 2026-10-07 09:08:14 +02:00
Lucas Vivo 1af47022e9 move combine audiobooks script
chmod
uncomment delete lines
2026-10-07 06:58:25 +02:00
Lucas Vivo ce656b6970 add metadata copy to combine media script 2026-10-07 06:38:36 +02:00
171 changed files with 9258 additions and 4943 deletions

No files matched your search

-2
View File
@@ -15,8 +15,6 @@ wheels/
.idea
uv.lock
youtubelist.txt
*.mkv
*.mp4
.spotdl-cache
script_template.txt
-60
View File
@@ -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}")
File diff suppressed because it is too large. Load diff
+172
View File
@@ -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())
-27
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
-63
View File
@@ -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}
-4
View File
@@ -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
-30
View File
@@ -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."
}
}
}
-33
View File
@@ -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.
+8
View File
@@ -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
+8
View File
@@ -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
+9
View File
@@ -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
+222
View File
@@ -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"
+3
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
/usr/bin/docker exec -u www-data nextcloud /usr/local/bin/php -f /var/www/html/cron.php
+39
View File
@@ -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;
@@ -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.$$"
+39
View File
@@ -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
+2
View File
@@ -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
@@ -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
@@ -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
View File
File diff suppressed because it is too large. Load diff
-183
View File
@@ -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
-8
View File
@@ -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.
+2
View File
@@ -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
View File
@@ -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")" ' \;
-98
View File
@@ -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
+305
View File
@@ -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")
-42
View File
@@ -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.
-105
View File
@@ -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
-190
View File
@@ -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()
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
import re
import sys
from pathlib import Path
from difflib import SequenceMatcher
def normalize(name):
name = Path(name).stem.lower()
# remove brackets
name = re.sub(r"\(.*?\)|\[.*?\]", "", name)
# replace punctuation with spaces
name = re.sub(r"[^a-z0-9]", " ", name)
# collapse whitespace
name = " ".join(name.split())
return name
def similarity(a, b):
words_a = set(normalize(a).split())
words_b = set(normalize(b).split())
if not words_a or not words_b:
return 0.0
# Jaccard similarity
intersection = words_a & words_b
union = words_a | words_b
return len(intersection) / len(union)
def load_playlist(path):
entries = []
with open(path, encoding="utf-8", errors="ignore") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
entries.append(Path(line).name)
return entries
def compare_playlists(orig_file, new_file, threshold=0.85):
orig_files = load_playlist(orig_file)
new_files = load_playlist(new_file)
print(f"\n=== {orig_file.parent} ===")
print(f"Comparing:")
print(f" {orig_file.name}")
print(f" {new_file.name}")
missing = []
for orig in orig_files:
best_match = None
best_score = 0
for candidate in new_files:
score = similarity(orig, candidate)
if score > best_score:
best_score = score
best_match = candidate
if best_score >= threshold:
pass
# print(
# f"MATCH {best_score:.1%} "
# f"{orig} -> {best_match}"
# )
else:
missing.append((orig, best_score, best_match))
if missing:
print("\nMISSING:")
for orig, score, best_match in sorted(
missing,
key=lambda x: x[1],
reverse=True,
):
print(
f" {orig}"
f" (best={score:.1%}"
f"{', candidate=' + best_match if best_match else ''})"
)
return len(missing)
def compare_directory(root, threshold=0.85):
total_missing = 0
playlist_pairs = 0
for orig_file in root.rglob("*_orig.m3u"):
new_file = orig_file.with_name(
orig_file.name.replace("_orig.m3u", ".m3u")
)
if not new_file.exists():
print(
f"\nWARNING: Missing playlist for "
f"{orig_file}"
)
continue
playlist_pairs += 1
total_missing += compare_playlists(
orig_file,
new_file,
threshold,
)
print("\n" + "=" * 80)
print(f"Playlists compared : {playlist_pairs}")
print(f"Total missing : {total_missing}")
def main():
args = sys.argv[1:]
threshold = 0.85
if len(args) == 3 and args[0] == "--threshold":
threshold = float(args[1])
args = [args[2]]
elif len(args) == 4 and args[0] == "--threshold":
threshold = float(args[1])
args = [args[2], args[3]]
if len(args) == 1:
path = Path(args[0])
if not path.is_dir():
print("Single argument must be a directory")
sys.exit(1)
compare_directory(path, threshold)
elif len(args) == 2:
compare_playlists(
Path(args[0]),
Path(args[1]),
threshold,
)
else:
print(
f"Usage:\n"
f" {Path(sys.argv[0]).name} "
f"<playlist_orig.m3u> <playlist.m3u>\n\n"
f" {Path(sys.argv[0]).name} "
f"<directory>\n\n"
f"Optional:\n"
f" --threshold 0.90"
)
sys.exit(1)
if __name__ == "__main__":
main()
@@ -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
+1 -47
View File
@@ -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.
+2287
View File
File diff suppressed because it is too large. Load diff
+309
View File
@@ -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()
+384
View File
@@ -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()
+770
View File
@@ -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.
+1 -1
View File
@@ -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