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