Загрузка данных


import os
import time
import json
import xml.etree.ElementTree as ET
from pathlib import Path
from threading import Lock

# ============================================================
# НАСТРОЙКИ
# ============================================================

MUSIC_ROOT = Path(r"D:\Музыка")  # Путь к вашей корневой папке
CHECK_INTERVAL = 2.0             # Интервал проверки изменений (в секундах)

AUDIO_EXTENSIONS = {
    ".aac", ".flac", ".m4a", ".mp3", 
    ".ogg", ".opus", ".wav", ".wma"
}

PIPE_PATH = r"\\.\pipe\sp_remote_control"


# ============================================================
# РАБОТА С SOUNDPAD API
# ============================================================

class SoundpadAPI:
    def __init__(self):
        self.lock = Lock()

    def send(self, command: str) -> str:
        with self.lock:
            try:
                with open(PIPE_PATH, "r+b", buffering=0) as pipe:
                    pipe.write(command.encode("utf-8"))
                    chunks = []
                    while True:
                        chunk = pipe.read(4096)
                        if not chunk:
                            break
                        chunks.append(chunk)
                        if len(chunk) < 4096:
                            break
                    return b"".join(chunks).decode("utf-8", errors="replace")
            except Exception:
                return ""

    def is_alive(self) -> bool:
        return self.send("IsAlive()").startswith("R-200")

    def get_categories_xml(self) -> str:
        return self.send("GetCategories(true, false)")

    def add_category(self, name: str, parent_index: int = -1) -> bool:
        cmd = f'DoAddCategory("{escape_rc(name)}", {parent_index})'
        return self.send(cmd).startswith("R-200")

    def add_sound(self, path: str, category_index: int, position: int = 0) -> bool:
        cmd = f'DoAddSound("{escape_rc(path)}", {category_index}, {position})'
        return self.send(cmd).startswith("R-200")

    def search(self, text: str) -> bool:
        return self.send(f'DoSearch("{escape_rc(text)}")').startswith("R-200")

    def remove_selected(self) -> bool:
        return self.send("DoRemoveSelectedEntries(false)").startswith("R-200")

    def reset_search(self) -> bool:
        return self.send("DoResetSearch()").startswith("R-200")


def escape_rc(value: str) -> str:
    return str(value).replace("\\", "\\\\").replace('"', '\\"')


# ============================================================
# ПАРСИНГ СТРУКТУРЫ SOUNDPAD (ПОД ВЕРСИЮ 4.0.35)
# ============================================================

def parse_soundpad_tree(xml_text: str):
    """
    Парсит XML от Soundpad 4.0.35.
    Учитывает атрибуты url в тегах <Sound> и вложенные теги <Category>.
    """
    if not xml_text or xml_text.startswith("R-"):
        return []

    try:
        root = ET.fromstring(xml_text)
    except Exception:
        return []

    categories = []

    def parse_category_node(elem, parent_idx=-1):
        idx_str = elem.attrib.get("index")
        name = elem.attrib.get("name")
        
        if idx_str is None or name is None:
            return

        try:
            cat_idx = int(idx_str)
        except ValueError:
            return

        sounds = []
        for child in elem:
            tag = child.tag.lower()
            if tag == "sound":
                url = child.attrib.get("url") or child.attrib.get("path")
                if url:
                    sounds.append(url)
            elif tag == "category":
                parse_category_node(child, parent_idx=cat_idx)

        categories.append({
            "index": cat_idx,
            "name": name,
            "parent": parent_idx,
            "sounds": sounds
        })

    for child in root:
        if child.tag.lower() == "category":
            parse_category_node(child, -1)

    return categories


def build_category_map(categories):
    """
    Строит словарь соответствия путей папок и ID категорий Soundpad.
    Пример: "Unknown Path\\Music" -> index
    """
    by_index = {c["index"]: c for c in categories}
    result = {}

    for c in categories:
        parts = [c["name"]]
        curr = c
        visited = set()
        
        while curr["parent"] in by_index and curr["parent"] not in visited:
            visited.add(curr["parent"])
            curr = by_index[curr["parent"]]
            parts.append(curr["name"])
            
        parts.reverse()
        # Игнорируем служебную категорию "Sounds" или "Все звуки" в корне
        clean_parts = [p for p in parts if p not in ("Все звуки", "Sounds")]
        if clean_parts:
            path_key = "\\".join(clean_parts)
            result[path_key.lower()] = c["index"]

    return result


# ============================================================
# ФАЙЛОВАЯ СИСТЕМА
# ============================================================

def get_disk_folders():
    folders = set()
    if not MUSIC_ROOT.exists():
        return folders

    for path in MUSIC_ROOT.rglob("*"):
        if path.is_dir():
            rel = path.relative_to(MUSIC_ROOT)
            if str(rel) != ".":
                folders.add(str(rel))
    return folders


def get_disk_files():
    files = {}
    if not MUSIC_ROOT.exists():
        return files

    for path in MUSIC_ROOT.rglob("*"):
        if path.is_file() and path.suffix.lower() in AUDIO_EXTENSIONS:
            rel = path.relative_to(MUSIC_ROOT)
            folder = str(rel.parent) if str(rel.parent) != "." else ""
            files[str(path).lower()] = {
                "full_path": str(path),
                "relative": str(rel),
                "folder": folder
            }
    return files


# ============================================================
# ЛОГИКА СИНХРОНИЗАЦИИ
# ============================================================

def sync_structure(sp: SoundpadAPI):
    if not MUSIC_ROOT.exists() or not sp.is_alive():
        return

    # 1. Синхронизация папок (категорий)
    disk_folders = sorted(list(get_disk_folders()), key=lambda x: (x.count("\\"), x.lower()))
    
    xml_data = sp.get_categories_xml()
    categories = parse_soundpad_tree(xml_data)
    cat_map = build_category_map(categories)

    for folder in disk_folders:
        if folder.lower() not in cat_map:
            parts = folder.split("\\")
            cat_name = parts[-1]
            parent_path = "\\".join(parts[:-1]).lower() if len(parts) > 1 else ""
            
            parent_idx = cat_map.get(parent_path, -1)
            
            print(f"[+] Создание категории: {folder}")
            if sp.add_category(cat_name, parent_idx):
                time.sleep(0.15)
                # Обновляем карту категорий после создания
                xml_data = sp.get_categories_xml()
                categories = parse_soundpad_tree(xml_data)
                cat_map = build_category_map(categories)

    # 2. Добавление новых файлов
    disk_files = get_disk_files()
    xml_data = sp.get_categories_xml()
    categories = parse_soundpad_tree(xml_data)
    cat_map = build_category_map(categories)

    # Собираем все текущие треки из Soundpad
    sp_sounds = set()
    for cat in categories:
        for snd in cat["sounds"]:
            sp_sounds.add(os.path.normcase(os.path.normpath(snd)))

    for file_key, info in disk_files.items():
        norm_file = os.path.normcase(os.path.normpath(info["full_path"]))
        if norm_file not in sp_sounds:
            folder = info["folder"]
            target_cat_idx = cat_map.get(folder.lower(), -1)
            
            # Если файл лежит в самом корне D:\Музыка, берем категорию "Sounds" (index 2)
            if target_cat_idx == -1 and folder == "":
                target_cat_idx = 2

            if target_cat_idx != -1:
                print(f"[+] Добавление трека: {info['relative']}")
                sp.add_sound(info["full_path"], target_cat_idx)
                time.sleep(0.08)

    # 3. Удаление пропавших файлов
    existing_disk_paths = {os.path.normcase(os.path.normpath(k)) for k in disk_files.keys()}
    
    for cat in categories:
        for sound_path in cat["sounds"]:
            try:
                snd_path_obj = Path(sound_path).resolve()
                root_obj = MUSIC_ROOT.resolve()
                snd_path_obj.relative_to(root_obj)
            except ValueError:
                # Файл лежит вне D:\Музыка - не трогаем его
                continue

            norm_sp_path = os.path.normcase(os.path.normpath(sound_path))
            if norm_sp_path not in existing_disk_paths:
                print(f"[-] Удаление из Soundpad: {sound_path}")
                if sp.search(sound_path):
                    time.sleep(0.05)
                    sp.remove_selected()
                    time.sleep(0.05)
                    sp.reset_search()


# ============================================================
# СНИМКИ СОСТОЯНИЯ (МОНИТОРИНГ)
# ============================================================

def get_snapshot():
    if not MUSIC_ROOT.exists():
        return ()
    
    snapshot = []
    for path in MUSIC_ROOT.rglob("*"):
        try:
            stat = path.stat()
            snapshot.append((str(path).lower(), stat.st_size, stat.st_mtime_ns))
        except (OSError, PermissionError):
            continue
    return tuple(sorted(snapshot))


# ============================================================
# ТОЧКА ВХОДА
# ============================================================

def main():
    print("=== Soundpad Auto Sync v1.0 ===")
    print(f"[*] Отслеживание папки: {MUSIC_ROOT}")
    
    sp = SoundpadAPI()
    
    while not sp.is_alive():
        print("[!] Soundpad не запущен. Ожидание запуска...")
        time.sleep(3)

    print("[+] Связь с Soundpad установлена.")
    print("[*] Первичная синхронизация...")
    sync_structure(sp)
    print("[+] Синхронизация завершена. Запущен мониторинг.")

    last_snapshot = get_snapshot()

    try:
        while True:
            time.sleep(CHECK_INTERVAL)
            if not sp.is_alive():
                print("[!] Связь с Soundpad потеряна. Ожидание...")
                while not sp.is_alive():
                    time.sleep(3)
                print("[+] Soundpad снова доступен.")
                sync_structure(sp)
                last_snapshot = get_snapshot()
                continue

            current_snapshot = get_snapshot()
            if current_snapshot != last_snapshot:
                print("\n[*] Обнаружены изменения в файлах/папках...")
                time.sleep(0.5) # Пауза для завершения записи файлов
                sync_structure(sp)
                last_snapshot = get_snapshot()

    except KeyboardInterrupt:
        print("\n[*] Мониторинг остановлен.")


if __name__ == "__main__":
    main()