move move old scripts

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

No files matched your search

File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env python3
import os
import re
import logging
import argparse
from pathlib import Path
from difflib import SequenceMatcher
WORKDIR = Path("/data/navidrome/music/")
PLAYLIST_DIR = WORKDIR / "playlists"
MUSIC_DIR = WORKDIR / "music"
AUDIO_EXTS = {".mp3", ".flac", ".m4a", ".ogg", ".opus", ".aac", ".wav", ".ape"}
TRACE = 5
score_threshold = 0.6
folder_score_threshold = 0.6
logging.addLevelName(TRACE, "TRACE")
weight_jaccard = 0.6
weight_fuzzy = 1 - weight_jaccard
class CustomLogger(logging.Logger):
def trace(self, msg: str, *args, **kwargs) -> None:
if self.isEnabledFor(TRACE):
self._log(TRACE, msg, args, **kwargs)
logging.setLoggerClass(CustomLogger)
logger: CustomLogger = logging.getLogger(__name__) # type: ignore[assignment]
def words(text):
return set(filter(None, re.split(r"[^a-z0-9]+", text.lower())))
def word_similarity(a: str, b: str) -> float:
return SequenceMatcher(None, a, b).ratio()
def score_match_jaccard(search_words, candidate_words):
intersection = search_words & candidate_words
union = search_words | candidate_words
if not union:
return 0.0
return len(intersection) / len(union)
def score_match_fuzzy(search_words, candidate_words):
total = 0.0
for sw in search_words:
best = 0.0
for cw in candidate_words:
best = max(best, word_similarity(sw, cw))
total += best
# logger.trace(f"Total: {total}/{len(search_words)}")
return total / len(search_words)
def combined_score(score_fuzzy: float, score_jaccard: float) -> float:
return score_fuzzy * weight_fuzzy + score_jaccard * weight_jaccard
def find_folder(directory: Path, folder: str) -> list[Path]:
matches: list[tuple[Path, float]] = []
search_words = words(folder)
for candidate in directory.iterdir():
if not candidate.is_dir():
continue
candidate_words = words(candidate.name)
score_fuzzy = score_match_fuzzy(search_words, candidate_words)
score_jaccard = score_match_jaccard(search_words, candidate_words)
score = combined_score(score_fuzzy, score_jaccard)
logger.trace(f"Candidate: {score:.2f}:{candidate_words}")
if score > folder_score_threshold:
matches.append((candidate, score))
ret = [
path
for path, _ in sorted(
matches,
key=lambda x: x[1],
reverse=True,
)
]
logger.debug(f"Find Folders Return: {ret}")
return ret
def find_replacement(old_path: Path):
p_old_path = Path(old_path)
playlist_filename = p_old_path.stem
# Find the second "music" directory
music_idx = p_old_path.parts.index("music", p_old_path.parts.index("music") + 1)
folder_count = len(p_old_path.parts) - music_idx - 2
logger.debug(f"Folder Count Index: {folder_count}")
try:
artist = p_old_path.parts[len(WORKDIR.parts) + 1] # /music/Artist/Album/File
logger.debug(f"Artist: {artist}")
except IndexError:
logger.error(f"No Artist found for {p_old_path}")
return None
if artist == "Musik":
artist_directories = [MUSIC_DIR / Path(playlist_filename.split(" - ")[0])]
else:
artist_directories = find_folder(MUSIC_DIR, artist)
if not artist_directories:
logger.error(f"Artist dir {artist_directories} dors not exist")
return None
logger.debug(f"Artist paths: {artist_directories}")
album_directories = []
subalbum_directories = []
search_dir_list = artist_directories
if folder_count >= 2:
for artist_dir in artist_directories:
try:
album = p_old_path.parts[
len(WORKDIR.parts) + 2
] # .../Artist/Album/File
logger.debug(f"Album: {album}")
except IndexError:
logger.error(f"No album found for {p_old_path}")
return None
album_directories.extend(find_folder(artist_dir, album))
if not album_directories:
logger.warning(f"Album dir for '{album}' does not exist")
if album_directories:
logger.debug(f"Album path: {album_directories}")
search_dir_list = album_directories
if folder_count >= 3:
for album_dir, score_fuzzy in album_directories:
try:
subalbum = p_old_path.parts[
len(WORKDIR.parts) + 3
] # .../Artist/Album/File
logger.debug(f"Sub-Album: {subalbum}")
except IndexError:
logger.error(f"No Sub-Album found for {p_old_path}")
return None
subalbum_directories.extend(find_folder(album_dir, subalbum))
if not subalbum_directories:
logger.error(f"Sub-Album dir for '{subalbum}' does not exist")
return None
if subalbum_directories:
logger.debug(f"Sub-Album path: {subalbum_directories}")
search_dir_list = subalbum_directories
if not search_dir_list:
logger.error(f"Search list empty")
return None
if (
len(artist_directories) == 1
and artist_directories[0].name.lower() in playlist_filename.lower()
):
logger.debug(f"Replace {artist_directories[0].name} in {playlist_filename}")
playlist_filename = re.sub(
artist_directories[0].name, "", playlist_filename, flags=re.IGNORECASE
)
logger.debug(f"Filename minus Artist: {playlist_filename}")
original = playlist_filename
playlist_filename = re.sub(
r"\s*[\(\[].*?[\)\]]",
"",
playlist_filename,
)
playlist_filename = re.sub(r"\s+", " ", playlist_filename).strip()
if playlist_filename != original:
logger.debug(f"Removed bracketed text: '{original}' -> '{playlist_filename}'")
search_words = words(playlist_filename)
best_match = None
best_score = 0
for search_dir in search_dir_list:
for root, _, files in os.walk(search_dir):
logger.debug(f"Search words {search_words}")
logger.debug(f"Walk Directory: {root}")
for filename in files:
candidate = Path(root) / filename
candidate_parts = candidate.stem.split(" - ")
logger.trace(f"Candidate: {candidate}")
if candidate.suffix.lower() not in AUDIO_EXTS:
continue
separator_count = len(candidate.parent.name.split(" - ")) - 1
if len(candidate_parts) == 4 + separator_count:
candidate_words = words(
" ".join(candidate_parts[2 + separator_count :])
)
filename_structure = "LidArr"
else:
filename_structure = "Fallback"
candidate_words = words(candidate.stem)
score_fuzzy = score_match_fuzzy(search_words, candidate_words)
score_jaccard = score_match_jaccard(search_words, candidate_words)
score = combined_score(score_fuzzy, score_jaccard)
logger.trace(
f"{filename_structure} Words: {candidate_words} {score:.2f} / {best_score:.2f}, "
f"{weight_fuzzy:.2} * {score_fuzzy:.2f} + {weight_jaccard:.2} * {score_jaccard:.2f} "
)
if score > best_score:
best_score = score
best_match = candidate
# Require all words to match
if best_match and best_score > score_threshold:
logger.debug(f"Best Match: {best_match}")
return best_match
logger.error(
f"No match found with score greater {score_threshold:.2f}, best match: {best_score:.2f}:{best_match}"
)
return None
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"path",
nargs="?",
default=PLAYLIST_DIR,
help="Playlist file or directory",
)
parser.add_argument(
"-ll",
"--log-level",
type=str.upper,
default="INFO",
choices=["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Set log level",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Dry run only",
)
args = parser.parse_args()
LOG_LEVELS = {
"TRACE": TRACE,
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARNING": logging.WARNING,
"ERROR": logging.ERROR,
"CRITICAL": logging.CRITICAL,
}
logging.basicConfig(
level=LOG_LEVELS[args.log_level.upper()],
format="%(asctime)s %(levelname)s %(message)s",
)
playlist_path = Path(args.path)
if playlist_path.is_file():
playlists = [playlist_path.absolute()]
logger.info(f"Edit single playlist: {playlists}")
elif playlist_path.is_dir():
playlists = [p for p in playlist_path.glob("*.m3u") if "_new.m3u" not in p.name]
logger.info(f"Edit all playlists in {playlist_path.name}: {playlists}")
else:
logger.error(f"{playlist_path} does not exist")
return
for playlist in playlists:
if "_new.m3u" in playlist.name:
continue
logger.info(f"\nProcessing {playlist}")
changed = False
output = []
with open(playlist, "r", encoding="utf-8", errors="ignore") as f:
lines = f.readlines()
for line in lines:
if not line or line.startswith("#"):
# output.append(line)
# Ignore EXINF line as they dont matter
continue
path = WORKDIR / Path(line.strip()).relative_to("/")
if not path.is_absolute() or path.exists():
output.append(line)
continue
logger.debug(f"Missing: {path}")
replacement = find_replacement(path)
if replacement:
logger.info(f"{path.name} -> {replacement.name}")
replacement = Path(
str(replacement).removeprefix("/data/navidrome/music")
)
output.append(str(replacement) + "\n")
changed = True
else:
logger.error(f" -> No match for {path}")
output.append(line + "\n")
if changed and not args.dry_run:
backup_dir = playlist.parent.with_name(f"{playlist.parent.name}-bak")
backup_playlist = backup_dir / playlist.name
if not backup_playlist.exists():
logger.info(f"Generate Backup playlist {backup_playlist}")
backup_playlist.write_text(
playlist.read_text(encoding="utf-8"), encoding="utf-8"
)
with open(
playlist.with_name(playlist.stem + ".m3u"),
"w",
encoding="utf-8",
# playlist.with_name(playlist.stem + "_new.m3u"), "w", encoding="utf-8"
) as f:
f.writelines(output)
logger.info("Updated playlist")
if __name__ == "__main__":
main()
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.