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


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

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

MUSIC_ROOT = Path(r"D:\music")

# Интервал проверки изменений.
CHECK_INTERVAL = 2.0

# Поддерживаемые Soundpad форматы.
AUDIO_EXTENSIONS = {
    ".aac",
    ".flac",
    ".m4a",
    ".mp3",
    ".ogg",
    ".opus",
    ".wav",
    ".wma",
}

# Файл состояния.
# Нужен для определения переименований/перемещений.
STATE_FILE = Path(__file__).with_name("soundpad_sync_state.json")


# ============================================================
# SOUNDpad Remote Control
# ============================================================

class Soundpad:
    PIPE_NAME = r"\\.\pipe\sp_remote_control"

    def __init__(self):
        self.handle = None
        self.lock = Lock()

    def connect(self):
        if self.handle is not None:
            return True

        try:
            self.handle = open(
                self.PIPE_NAME,
                "r+b",
                buffering=0
            )
            return True
        except Exception:
            self.handle = None
            return False

    def disconnect(self):
        if self.handle:
            try:
                self.handle.close()
            except Exception:
                pass

        self.handle = None

    def request(self, command):
        with self.lock:
            if not self.connect():
                return ""

            try:
                data = command.encode("utf-8")

                self.handle.write(data)

                # Soundpad отдаёт ответ через pipe.
                first = self.handle.read(1)

                if not first:
                    self.disconnect()
                    return ""

                # Читаем остальные доступные данные.
                chunks = [first]

                while True:
                    chunk = self.handle.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:
                self.disconnect()
                return ""

    def alive(self):
        response = self.request("IsAlive()")
        return response.startswith("R-200")

    def version(self):
        return self.request("GetVersion()")

    def remote_version(self):
        return self.request("GetRemoteControlVersion()")

    def get_categories(self):
        return self.request("GetCategories(true, false)")

    def add_category(self, name, parent_index=-1):
        command = (
            f'DoAddCategory("{escape_rc(name)}", '
            f'{parent_index})'
        )

        response = self.request(command)

        return response.startswith("R-200")

    def remove_category(self, index):
        response = self.request(
            f"DoRemoveCategory({index})"
        )

        return response.startswith("R-200")

    def add_sound(self, path, category_index, position=0):
        command = (
            f'DoAddSound("{escape_rc(str(path))}", '
            f'{category_index}, {position})'
        )

        response = self.request(command)

        return response.startswith("R-200")

    def remove_selected(self):
        response = self.request(
            "DoRemoveSelectedEntries(false)"
        )

        return response.startswith("R-200")

    def select_row(self, row):
        response = self.request(
            f"DoSelectIndex({row})"
        )

        return response.startswith("R-200")

    def search(self, text):
        response = self.request(
            f'DoSearch("{escape_rc(text)}")'
        )

        return response.startswith("R-200")

    def reset_search(self):
        response = self.request(
            "DoResetSearch()"
        )

        return response.startswith("R-200")


def escape_rc(value):
    """
    Экранирование строки для Soundpad RC.
    """
    return (
        str(value)
        .replace("\\", "\\\\")
        .replace('"', '\\"')
    )


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

def get_audio_files():
    result = {}

    if not MUSIC_ROOT.exists():
        return result

    for path in MUSIC_ROOT.rglob("*"):
        if not path.is_file():
            continue

        if path.suffix.lower() not in AUDIO_EXTENSIONS:
            continue

        relative = path.relative_to(MUSIC_ROOT)

        result[str(relative).lower()] = {
            "path": str(path),
            "relative": str(relative),
            "folder": str(relative.parent),
            "name": path.name,
            "size": path.stat().st_size,
            "mtime": path.stat().st_mtime,
        }

    return result


def get_folders():
    result = set()

    if not MUSIC_ROOT.exists():
        return result

    for path in MUSIC_ROOT.rglob("*"):
        if path.is_dir():
            relative = path.relative_to(MUSIC_ROOT)

            if str(relative) != ".":
                result.add(str(relative))

    return result


# ============================================================
# STATE
# ============================================================

def load_state():
    if not STATE_FILE.exists():
        return {
            "files": {},
            "folders": {},
        }

    try:
        with open(
            STATE_FILE,
            "r",
            encoding="utf-8"
        ) as f:
            return json.load(f)

    except Exception:
        return {
            "files": {},
            "folders": {},
        }


def save_state(state):
    temp = STATE_FILE.with_suffix(".tmp")

    with open(
        temp,
        "w",
        encoding="utf-8"
    ) as f:
        json.dump(
            state,
            f,
            ensure_ascii=False,
            indent=2
        )

    temp.replace(STATE_FILE)


# ============================================================
# SOUNDpad XML
# ============================================================

def parse_soundpad_categories(xml_text):
    """
    Получает дерево категорий Soundpad.

    Возвращает:

    [
        {
            "index": 1,
            "name": "...",
            "parent": -1,
            "sounds": [...]
        }
    ]

    Формат XML может немного отличаться между версиями,
    поэтому здесь используется максимально терпимый парсер.
    """

    if not xml_text or xml_text.startswith("R-"):
        return []

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

    categories = []

    # Собираем все элементы, похожие на Category.
    for element in root.iter():

        tag = element.tag.lower()

        if not tag.endswith("category"):
            continue

        attrs = {
            k.lower(): v
            for k, v in element.attrib.items()
        }

        name = (
            attrs.get("name")
            or attrs.get("title")
            or attrs.get("text")
        )

        index = (
            attrs.get("index")
            or attrs.get("id")
        )

        parent = (
            attrs.get("parent")
            or attrs.get("parentindex")
            or attrs.get("parentcategoryindex")
        )

        if name is None:
            continue

        try:
            index = int(index)
        except Exception:
            index = len(categories)

        try:
            parent = int(parent)
        except Exception:
            parent = -1

        sounds = []

        for child in element.iter():

            child_tag = child.tag.lower()

            if not (
                child_tag.endswith("sound")
                or child_tag.endswith("file")
                or child_tag.endswith("entry")
            ):
                continue

            child_attrs = {
                k.lower(): v
                for k, v in child.attrib.items()
            }

            sound_path = (
                child_attrs.get("path")
                or child_attrs.get("url")
                or child_attrs.get("file")
                or child_attrs.get("filename")
            )

            if sound_path:
                sounds.append(sound_path)

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

    return categories


# ============================================================
# СОЗДАНИЕ СТРУКТУРЫ
# ============================================================

def normalize_folder(folder):
    folder = str(folder)

    if folder in ("", "."):
        return ""

    return folder.replace("/", "\\")


def folder_parts(folder):
    folder = normalize_folder(folder)

    if not folder:
        return []

    return [
        x
        for x in folder.split("\\")
        if x
    ]


def build_required_folders():
    """
    Возвращает все папки от корня вниз.

    Например:

    Unknown Path
    Unknown Path\\Music
    Unknown Path\\Music\\Bosses
    """

    folders = set()

    for folder in get_folders():

        parts = folder_parts(folder)

        for i in range(1, len(parts) + 1):
            folders.add(
                "\\".join(parts[:i])
            )

    return sorted(
        folders,
        key=lambda x: (
            len(folder_parts(x)),
            x.lower()
        )
    )


# ============================================================
# ПОИСК КАТЕГОРИИ
# ============================================================

def category_path_map(categories):
    """
    Строит:

    "Minecraft" -> index
    "Minecraft\\Unknown Path" -> index
    """

    by_index = {
        c["index"]: c
        for c in categories
    }

    result = {}

    def make_path(category):
        parent = category["parent"]

        parts = [category["name"]]

        visited = set()

        while parent in by_index:
            if parent in visited:
                break

            visited.add(parent)

            parent_category = by_index[parent]

            parts.append(
                parent_category["name"]
            )

            parent = parent_category["parent"]

        parts.reverse()

        return "\\".join(parts)

    for category in categories:
        result[
            make_path(category)
        ] = category["index"]

    return result


# ============================================================
# СОЗДАНИЕ КАТЕГОРИЙ
# ============================================================

def sync_categories(sp):
    required = build_required_folders()

    if not required:
        return

    xml = sp.get_categories()

    categories = parse_soundpad_categories(xml)

    existing = category_path_map(categories)

    for folder in required:

        if folder in existing:
            continue

        parts = folder_parts(folder)

        name = parts[-1]

        if len(parts) == 1:
            parent_index = -1
        else:
            parent_path = "\\".join(parts[:-1])

            parent_index = existing.get(
                parent_path,
                -1
            )

        print(
            f"[CATEGORY +] {folder}"
        )

        if sp.add_category(
            name,
            parent_index
        ):
            time.sleep(0.15)

            # Обновляем дерево после добавления.
            xml = sp.get_categories()

            categories = parse_soundpad_categories(
                xml
            )

            existing = category_path_map(
                categories
            )

        else:
            print(
                f"[ERROR] Не удалось создать категорию: "
                f"{folder}"
            )


# ============================================================
# ПОИСК ЗВУКА В SOUNDpad
# ============================================================

def get_soundpad_sound_map(sp):
    """
    Возвращает:

    полный_путь_к_файлу -> информация
    """

    xml = sp.get_categories()

    categories = parse_soundpad_categories(xml)

    result = {}

    for category in categories:

        for sound in category["sounds"]:

            normalized = os.path.normcase(
                os.path.normpath(sound)
            )

            result[normalized] = {
                "category": category["index"],
                "category_name": category["name"],
                "path": sound,
            }

    return result


# ============================================================
# ДОБАВЛЕНИЕ НОВЫХ ФАЙЛОВ
# ============================================================

def add_missing_sounds(sp):

    files = get_audio_files()

    xml = sp.get_categories()

    categories = parse_soundpad_categories(xml)

    folder_map = category_path_map(categories)

    # Сначала создаём недостающие категории.
    sync_categories(sp)

    # Получаем обновлённую карту.
    xml = sp.get_categories()

    categories = parse_soundpad_categories(xml)

    folder_map = category_path_map(categories)

    sound_map = get_soundpad_sound_map(sp)

    for key, info in files.items():

        path = os.path.normcase(
            os.path.normpath(
                info["path"]
            )
        )

        if path in sound_map:
            continue

        folder = normalize_folder(
            info["folder"]
        )

        if not folder:
            # Файлы прямо в D:\music
            # добавляем в All sounds / текущую структуру.
            continue

        category_index = folder_map.get(folder)

        if category_index is None:
            print(
                f"[ERROR] Нет категории: {folder}"
            )
            continue

        print(
            f"[SOUND +] {info['relative']}"
        )

        if sp.add_sound(
            info["path"],
            category_index,
            0
        ):
            time.sleep(0.08)
        else:
            print(
                f"[ERROR] Не удалось добавить: "
                f"{info['path']}"
            )


# ============================================================
# УДАЛЕНИЕ ПРОПАВШИХ ФАЙЛОВ
# ============================================================

def remove_missing_sounds(sp):

    files = get_audio_files()

    existing_paths = {
        os.path.normcase(
            os.path.normpath(
                info["path"]
            )
        )
        for info in files.values()
    }

    xml = sp.get_categories()

    categories = parse_soundpad_categories(xml)

    removed_any = False

    for category in categories:

        for sound in category["sounds"]:

            normalized = os.path.normcase(
                os.path.normpath(sound)
            )

            # Нас интересуют только файлы из D:\music.
            try:
                sound_path = Path(sound).resolve()
                root_path = MUSIC_ROOT.resolve()

                sound_path.relative_to(root_path)

            except Exception:
                continue

            if normalized in existing_paths:
                continue

            print(
                f"[SOUND -] {sound}"
            )

            # Soundpad удаляет выбранную запись.
            #
            # Поэтому сначала ищем файл через поиск.
            if sp.search(sound):
                time.sleep(0.05)

                if sp.remove_selected():
                    removed_any = True
                    time.sleep(0.1)

                sp.reset_search()

    return removed_any


# ============================================================
# ПЕРВОНАЧАЛЬНАЯ СИНХРОНИЗАЦИЯ
# ============================================================

def full_sync(sp):

    print()
    print("=" * 60)
    print("Soundpad Sync")
    print("=" * 60)
    print(
        f"Источник: {MUSIC_ROOT}"
    )
    print()

    if not MUSIC_ROOT.exists():
        print(
            f"[ERROR] Папка не существует: "
            f"{MUSIC_ROOT}"
        )
        return

    if not sp.alive():
        print(
            "[ERROR] Soundpad не запущен "
            "или Remote Control недоступен."
        )
        return

    print(
        "[OK] Soundpad:",
        sp.version()
    )

    print(
        "[OK] Remote Control:",
        sp.remote_version()
    )

    print()

    # 1. Категории.
    sync_categories(sp)

    # 2. Новые файлы.
    add_missing_sounds(sp)

    # 3. Удалённые файлы.
    remove_missing_sounds(sp)

    print()
    print("[OK] Синхронизация завершена.")
    print()


# ============================================================
# НАБЛЮДЕНИЕ ЗА ПАПКОЙ
# ============================================================

def make_files_signature():
    """
    Быстрый снимок файловой структуры.
    """

    result = []

    for path in MUSIC_ROOT.rglob("*"):

        try:
            if not path.is_file():
                continue

            if path.suffix.lower() not in AUDIO_EXTENSIONS:
                continue

            stat = path.stat()

            result.append((
                str(
                    path.relative_to(MUSIC_ROOT)
                ).lower(),
                stat.st_size,
                stat.st_mtime_ns
            ))

        except (OSError, PermissionError):
            continue

    return tuple(
        sorted(result)
    )


def make_folders_signature():

    result = []

    for path in MUSIC_ROOT.rglob("*"):

        try:
            if path.is_dir():

                result.append(
                    str(
                        path.relative_to(MUSIC_ROOT)
                    ).lower()
                )

        except (OSError, PermissionError):
            continue

    return tuple(
        sorted(result)
    )


# ============================================================
# MAIN
# ============================================================

def main():

    print("Soundpad folder synchronizer")
    print()
    print(
        f"Музыка: {MUSIC_ROOT}"
    )
    print(
        f"Интервал: {CHECK_INTERVAL} сек."
    )
    print()

    if not MUSIC_ROOT.exists():

        print(
            f"ОШИБКА: папка {MUSIC_ROOT} "
            f"не существует."
        )

        input(
            "\nНажми Enter для выхода..."
        )

        return

    sp = Soundpad()

    # Первая синхронизация.
    while not sp.alive():

        print(
            "Soundpad не найден."
        )

        print(
            "Запусти Soundpad."
        )

        print(
            "Повторная попытка через 5 секунд..."
        )

        time.sleep(5)

    full_sync(sp)

    last_files = make_files_signature()
    last_folders = make_folders_signature()

    print(
        "Наблюдение запущено."
    )

    print(
        "Для выхода нажми Ctrl+C."
    )

    print()

    try:

        while True:

            time.sleep(
                CHECK_INTERVAL
            )

            current_files = (
                make_files_signature()
            )

            current_folders = (
                make_folders_signature()
            )

            changed = (
                current_files != last_files
                or
                current_folders != last_folders
            )

            if not changed:
                continue

            print()
            print(
                "[CHANGE] Обнаружены изменения."
            )

            # Небольшая задержка.
            # Полезно при копировании больших файлов:
            # скрипт не будет пытаться добавить
            # недокачанный MP3.
            time.sleep(0.5)

            try:

                if not sp.alive():

                    print(
                        "[INFO] Soundpad закрыт. "
                        "Ждём его запуска..."
                    )

                    while not sp.alive():

                        time.sleep(2)

                    print(
                        "[OK] Soundpad снова доступен."
                    )

                full_sync(sp)

            except Exception as e:

                print(
                    "[ERROR]",
                    repr(e)
                )

            last_files = (
                make_files_signature()
            )

            last_folders = (
                make_folders_signature()
            )

    except KeyboardInterrupt:

        print()
        print(
            "Синхронизация остановлена."
        )

    finally:

        sp.disconnect()


if __name__ == "__main__":
    main()