Загрузка данных
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()