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