Это руководство для того, кто решил поднять всё сам. От пустого LXC-контейнера до работающего стека: свой стриминг, поиск и скачивание музыки с телефона, приватность через VPN и рекомендации «по звуку». Разбираем каждый из двенадцати сервисов — что ставим, зачем и как настроить.

С чего всё началось

YouTube Music я использую довольно давно и с подпиской, полез чистить «Понравившееся» — а часть треков оттуда просто исчезла: где-то удалили клип, где-то канал в бане, где-то правообладатель передумал. Плейлист, который я собирал годами, тихо усыхал, и меня не спросили. Причем у меня есть подписка. Ну и обойдусь.

Заменить стриминг «включил — играет любой трек мира» самохостинг не может, каталог из десятков миллионов треков дома не поднять. Задача другая — забрать под контроль то, что реально ценно: свою коллекцию, свою курацию и механику «услышал → добавил → получил похожее» в пару кликов. Ну и, конечно, мы не пираты.

Чего мы хотим

Я не слежу за артистами и не качаю дискографии — мне нужно, чтобы играла моя коллекция, чтобы услышанный трек добавлялся одной кнопкой, и чтобы система подсовывала похожее. Отсюда цели:

  1. Своя коллекция на своём диске. Ядро — самохостовый аудио-сервер (Navidrome, протокол OpenSubsonic): стриминг, плейлисты, рейтинги. Файлы у меня и не исчезают от чужих решений.

  2. Поиск и добавление трека с телефона. Услышал — за пару тапов в коллекции, теггировано и разложено. Без «подойди к ноутбуку, запусти Х».

  3. Приватность, домашний IP не светится. Всё внешнее приобретение — через VPN с fail-closed killswitch. Soulseek (P2P-раздача — главный DMCA-вектор) живёт целиком внутри сетевого namespace VPN.

  4. Перенос курации. Годы лайков, рейтингов, плейлистов и «мёртвые» треки (те, что Google уже удалил) — не потерять, а перенести в нативные механизмы Navidrome.

  5. Sonic-рекомендации. Локальный анализ звука («дай похожее на этот трек») плюс weekly-рекомендации по вкусу для открытия нового.

  6. В сумме — замена YTM. На телефоне один клиент (Symfonium под Android), в нём моя музыка, лайки, поиск-добавление и радио. Zero-subscription — ни одной активной подписки.

Архитектура

Одна мысль, из которой растёт весь стек:

Navidrome читает каталог /music и строит библиотеку по тегам внутри файлов. SoulSync — это то, что /music наполняет. Остальное — обвязка вокруг этих двух ролей.

Держа это в голове, двенадцать контейнеров складываются в понятную трубу источники → SoulSync → /music → Navidrome → телефон. Ядро — ровно два сервиса; прочие десять — либо приватность, либо «умные» фичи, либо клей.

      ТЕЛЕФОН — Symfonium + клиентский серт .p12
                     │ HTTPS :443, mTLS-гейт (нет серта → connection refused)
                     ▼
   edge-Caddy:  tls { client_auth trust music-CA }   nvd.example.com
                     │ reverse_proxy → внутренний caddy (Host сохраняется)
   ══════ LXC «music» (unprivileged, Docker) ══════
                     ▼
   caddy (внутренний ingress :80) — Host-роутинг локальных UI
                     ▼
   navidrome :4533  ◄── плагин audiomuseai.ndp (sonicSimilarity)
   ЧИТАЕТ /music ПО ТЕГАМ           ▲ анализ звука
        ▲                    audiomuse ×4 (flask+worker+postgres+redis)
        │ читает /music (ro)
        │ ПИШЕТ (file_organization=ON)
   SoulSync :8008 (+ PWA на телефон)
        │ всё внешнее — через VPN
        ▼
   gluetun (WireGuard-VPN, killswitch fail-closed)
     ├─ HTTP-прокси :8888 ← YouTube/Deezer
     └─ netns: slskd (Soulseek P2P), prowlarr+qbit (торренты, opt-in)
     egress = VPN. Домашний/VPS IP наружу не светится.

   explo — раз в неделю пишет плейлист-рекомендацию в Navidrome

Со схемы важно прочитать две вещи. Первое: телефон ходит только К Navidrome, «сверху вниз» — у VPN нет входящего, приватность телефонного трафика держит mTLS на edge, а VPN закрывает исходящий трафик качалок. Второе: /music — одна физическая папка (bind-mount), в которую SoulSync пишет, а Navidrome читает; никакого копирования, никакого промежуточного каталога. Про mTLS важно отметить, что это Caddy reverse proxy и в моих прошлых статьях описано, что это самый край домашней инфры, торчащий в интернет, второй Caddy из текста ниже, это внутри LXC просто для удобства. Внешний Caddy заставить просить mTLS мне нужно было потому, что дырявым музыкальным веб-серверам я не доверяю, а проигрыватель Symfonium как раз поддерживает аутентификацию по сертификату и делает это более надежный Caddy. Не забудьте только в телефон сертификат установить, на андроиде, в настройках. Расписывать как сгенерировать сертификат и установить я не буду, разберетесь. Тогда какие бы дырявые ни были музыкальные сервисы, защищает их внешний reverse proxy.

Контейнер

Образ

Роль

navidrome

deluan/navidrome:0.63.2

Ядро. OpenSubsonic-сервер: /music по тегам, стриминг, плейлисты, рейтинги

soulsync

boulderbadgedad/soulsync:3.0.1

Ядро. Поиск+скачивание (YouTube/Soulseek/торренты/Deezer/…), пишет в /music, тегирует. PWA на телефон

slskd

slskd/slskd:latest

Soulseek P2P — источник редкого лосслесса. В netns gluetun

gluetun

qmcgaw/gluetun:latest

VPN-шлюз (WireGuard) + killswitch. Оборачивает качалки; HTTP-прокси :8888

prowlarr / qbittorrent

lscr.io/linuxserver/*

Торрент-индексеры + клиент. Профиль torrents, opt-in. В netns gluetun

audiomuse (flask + worker)

ghcr.io/neptunehub/audiomuse-ai:2.6.2

Sonic-анализ звука (Essentia + CLAP-эмбеддинги): похожие треки, плейлисты

audiomuse-db / -redis

postgres:15, redis:7

Хранилище эмбеддингов и очередь задач анализа

explo

ghcr.io/lumepart/explo:latest

Weekly-рекомендации ListenBrainz → плейлист в Navidrome

caddy

caddy:2-alpine

Внутренний reverse-proxy: Host-роутинг локальных UI, единая точка за edge-mTLS

Установка

Шаг 0. LXC — то, что делается только с хоста

Стек живёт в отдельном unprivileged-LXC. Gluetun нужен туннельный интерфейс, который LXC по умолчанию не отдаёт:

# на хосте Proxmox, под root (117 — номер вашего CT)
pct set 117 --dev0 /dev/net/tun

Дальше всё внутри контейнера и под обычным пользователем (у меня user, uid 1000) — root понадобится только для установки docker и systemd-юнита автозапуска. Это осознанное решение: чем меньше крутится под root, тем меньше поверхность, если один из образов окажется недобросовестным.

Шаг 1. Каталоги — одно дерево, один владелец

# внутри CT, под обычным пользователем
mkdir -p ~/music-app/{navidrome/plugins,soulsync/{config,logs},slskd,prowlarr,qbit,caddy}
mkdir -p ~/music-app/audiomuse/{pgdata,redis-data,temp-flask,temp-worker,plugins-flask,plugins-worker}
mkdir -p ~/music-app/explo/config
mkdir -p ~/music-app/data/{media/music,soulseek/downloads,torrents}
cd ~/music-app

data/media/music — это и есть тот самый /music, единственная папка с музыкой. Всё дерево принадлежит uid 1000; за этим стоит следить (см. грабли про root-owned каталоги).

Шаг 2. Секреты — .env с chmod 600

Пароли и ключи генерируем на месте и держим только в .env, не в compose и не в образах:

{
  echo "TZ=Europe/Amsterdam"
  echo "PUID=1000"; echo "PGID=1000"; echo "UMASK=002"
  echo "AM_DB_PASSWORD=$(openssl rand -hex 24)"          # Postgres AudioMuse
  echo "ND_PASSWORDENCRYPTIONKEY=$(openssl rand -hex 24)" # шифрование паролей Navidrome
  echo "SLSK_USER=music-$(openssl rand -hex 4)"           # Soulseek-аккаунт заводится первым логином
  echo "SLSK_PASS=$(openssl rand -hex 16)"
  echo "EXPLO_UI_PASSWORD=$(openssl rand -hex 8)"
} >> .env
chmod 600 .env

Отдельно — приватный ключ WireGuard от вашего VPN-провайдера. Его вставляем через невидимый ввод, чтобы он не попал ни на экран, ни в history:

read -rs WGKEY
printf 'WG_PRIVATE_KEY=%s\n' "$WGKEY" >> .env

Шаг 3. docker-compose.yml — весь стек

Здесь есть одна сетевая ловушка, на которой легко потерять полдня. Контейнеры за network_mode: service:gluetun наследуют DNS gluetun и перестают резолвить docker-имена — SoulSync теряет связь с navidrome и slskd. Поэтому в туннель VPN мы сажаем только качалки (slskd, prowlarr, qbit), а SoulSync живёт в обычной сети (docker-DNS работает) и гонит наружу через HTTP-прокси gluetun. Ниже это уже учтено.

name: music

networks:
  default:
    name: music
    ipam:
      config:
        - subnet: 172.28.0.0/24   # фиксируем подсеть, чтобы не пересечься с диапазоном VPN

x-env: &common-env
  TZ: ${TZ}
  PUID: ${PUID}
  PGID: ${PGID}
  UMASK: ${UMASK}

services:
  # ── музыкальный сервер (наружу через edge-Caddy с mTLS) ──
  navidrome:
    image: deluan/navidrome:0.63.2
    container_name: navidrome
    user: "1000:1000"
    environment:
      ND_SCANSCHEDULE: 1h
      ND_PLUGINS_ENABLED: "true"           # нужно для плагина AudioMuse
      ND_PLUGINS_AUTORELOAD: "true"
      ND_AGENTS: "audiomuseai,deezer"      # audiomuseai первым — sonic-похожесть
      ND_DEVARTISTINFOTIMETOLIVE: "1s"     # не отдавать similar/radio из кэша
      ND_PASSWORDENCRYPTIONKEY: ${ND_PASSWORDENCRYPTIONKEY}
      TZ: ${TZ}
    volumes:
      - ./navidrome:/data
      - ./data/media/music:/music:ro       # библиотека — только на чтение
    restart: unless-stopped

  # ── AudioMuse-AI: sonic-анализ (flask + worker + postgres + redis) ──
  audiomuse:
    image: ghcr.io/neptunehub/audiomuse-ai:2.6.2
    container_name: audiomuse-ai-flask-app
    user: "1000:1000"
    environment: &am-env
      SERVICE_TYPE: "flask"
      POSTGRES_HOST: audiomuse-db
      POSTGRES_USER: audiomuse
      POSTGRES_PASSWORD: ${AM_DB_PASSWORD}
      POSTGRES_DB: audiomusedb
      REDIS_URL: redis://audiomuse-redis:6379/0
      TEMP_DIR: /app/temp_audio
      TZ: ${TZ}
    volumes:
      - ./audiomuse/temp-flask:/app/temp_audio
      - ./audiomuse/plugins-flask:/app/plugin/installed
    depends_on: [audiomuse-db, audiomuse-redis]
    restart: unless-stopped

  audiomuse-worker:
    image: ghcr.io/neptunehub/audiomuse-ai:2.6.2
    container_name: audiomuse-ai-worker
    user: "1000:1000"
    environment:
      <<: *am-env
      SERVICE_TYPE: "worker"
    volumes:
      - ./audiomuse/temp-worker:/app/temp_audio
      - ./audiomuse/plugins-worker:/app/plugin/installed
      - ./data/media/music:/music:ro       # воркер читает аудио для анализа
    depends_on: [audiomuse-db, audiomuse-redis]
    cpus: "4.0"                             # анализ тяжёлый — ограничиваем, чтобы не душить соседей на хосте
    mem_limit: 4g
    restart: unless-stopped

  audiomuse-redis:
    image: redis:7-alpine
    container_name: audiomuse-redis
    user: "1000:1000"
    volumes: [./audiomuse/redis-data:/data]
    restart: unless-stopped

  audiomuse-db:
    image: postgres:15-alpine
    container_name: audiomuse-db
    user: "1000:1000"
    command: ["postgres", "-c", "unix_socket_directories=/tmp"]
    environment:
      POSTGRES_USER: audiomuse
      POSTGRES_PASSWORD: ${AM_DB_PASSWORD}
      POSTGRES_DB: audiomusedb
    volumes: [./audiomuse/pgdata:/var/lib/postgresql/data]
    restart: unless-stopped

  # ── VPN-шлюз: WireGuard + killswitch ──
  gluetun:
    image: qmcgaw/gluetun:latest
    container_name: gluetun
    cap_add: [NET_ADMIN]
    devices: ["/dev/net/tun:/dev/net/tun"]
    ports:
      - "5030:5030"   # slskd Web UI
      - "8080:8080"   # qBittorrent (профиль torrents)
      - "9696:9696"   # Prowlarr    (профиль torrents)
    environment:
      VPN_SERVICE_PROVIDER: nordvpn        # ваш провайдер
      VPN_TYPE: wireguard
      WIREGUARD_PRIVATE_KEY: ${WG_PRIVATE_KEY}
      SERVER_COUNTRIES: Switzerland        # DMCA-safe юрисдикция
      SERVER_CATEGORIES: P2P
      FIREWALL: "on"                       # killswitch fail-closed: туннель упал → трафик дропается
      HTTPPROXY: "on"                      # :8888 для HTTP-качалок (SoulSync)
      TZ: ${TZ}
    restart: unless-stopped

  # ── Soulseek-демон (целиком в туннеле VPN) ──
  slskd:
    image: slskd/slskd:latest
    container_name: slskd
    user: "1000:1000"
    network_mode: "service:gluetun"
    depends_on:
      gluetun: { condition: service_healthy }
    environment:
      SLSKD_SLSK_USERNAME: ${SLSK_USER}
      SLSKD_SLSK_PASSWORD: ${SLSK_PASS}
      SLSKD_SLSK_LISTEN_PORT: "50300"      # входящий порт для раздачи (иначе uploads=0)
      TZ: ${TZ}
    volumes:
      - ./slskd:/app
      - ./data:/data
    restart: unless-stopped

  # ── SoulSync: поиск+скачивание (наружу через edge-Caddy) ──
  soulsync:
    image: boulderbadgedad/soulsync:3.0.1  # конкретный тег, НЕ latest (проект очень активен — см. грабли)
    container_name: soulsync-webui
    environment:
      - PUID=1000
      - PGID=1000
      - UMASK=022
      - TZ=${TZ}
      - HTTP_PROXY=http://gluetun:8888     # внешнее (YouTube/Deezer) → VPN
      - HTTPS_PROXY=http://gluetun:8888
      - NO_PROXY=localhost,127.0.0.1,navidrome,gluetun,slskd.music,caddy,172.28.0.0/16,.music # внутреннее → напрямую; gluetun обязателен — иначе запросы к Prowlarr/qBit уйдут в прокси
    volumes:
      - ./soulsync/config:/app/config
      - ./soulsync/logs:/app/logs
      - ./data/soulseek/downloads:/app/downloads
      - ./data/media/music:/app/Transfer   # SoulSync пишет ПРЯМО в /music
      - ./data/torrents:/data/torrents
      - soulsync_database:/app/data
    depends_on: [slskd]
    restart: unless-stopped

  # ── Торренты (опционально, профиль torrents; в туннеле VPN) ──
  prowlarr:
    image: lscr.io/linuxserver/prowlarr:latest
    container_name: prowlarr
    profiles: ["torrents"]
    network_mode: "service:gluetun"
    depends_on: { gluetun: { condition: service_healthy } }
    environment: *common-env
    volumes: [./prowlarr:/config]
    restart: unless-stopped

  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent:latest
    container_name: qbittorrent
    profiles: ["torrents"]
    network_mode: "service:gluetun"
    depends_on: { gluetun: { condition: service_healthy } }
    environment:
      <<: *common-env
      WEBUI_PORT: "8080"
    volumes:
      - ./qbit:/config
      - ./data:/data
    restart: unless-stopped

  # ── Explo: weekly-рекомендации ListenBrainz → плейлист ──
  explo:
    image: ghcr.io/lumepart/explo:latest
    container_name: explo
    user: "1000:1000"
    environment:
      TZ: ${TZ}
      WEB_UI: "true"
      UI_USERNAME: admin
      UI_PASSWORD: ${EXPLO_UI_PASSWORD}
      HTTP_PROXY: http://gluetun:8888
      HTTPS_PROXY: http://gluetun:8888
      NO_PROXY: localhost,127.0.0.1,navidrome,gluetun,slskd.music,caddy,172.28.0.0/16,.music
    volumes:
      - ./explo/config:/opt/explo/config
      - ./data/media/music/Explo:/data/
      - ./data/soulseek/downloads:/slskd/
    depends_on:
      gluetun: { condition: service_healthy }
      navidrome: { condition: service_started }
    restart: unless-stopped

  # ── внутренний reverse-proxy (Host-роутинг локальных UI) ──
  caddy:
    image: caddy:2-alpine
    container_name: caddy
    user: "1000:1000"
    cap_add: [NET_BIND_SERVICE]
    ports: ["80:80"]
    volumes: [./caddy/Caddyfile:/etc/caddy/Caddyfile:ro]
    depends_on: { gluetun: { condition: service_healthy } }
    restart: unless-stopped
    networks:
      default:
        aliases: [nvd.music, ss.music, slskd.music, audiomuse.music]

volumes:
  soulsync_database:

Шаг 4. Внутренний Caddy — маршрутизация локальных UI

Внутренний caddy отдаёт веб-морды сервисов по внутренним именам (снаружи их закрывает edge-Caddy с mTLS). Минимальный caddy/Caddyfile:

nvd.example.com       { reverse_proxy navidrome:4533 }
ss.example.com        { reverse_proxy soulsync-webui:8008 }
audiomuse.example.com { reverse_proxy audiomuse-ai-flask-app:8000 }
slskd.example.com     { reverse_proxy gluetun:5030 }

Наружу эти хосты проксирует ваша edge-нода, добавляя mTLS (клиентский сертификат). Как устроен edge-слой с mTLS — тема отдельная; здесь важно, что телефон ходит на nvd.example.com за сертификатом, а всё остальное недоступно снаружи вовсе.

Шаг 5. Запуск и проверка, что VPN держит

docker compose up -d                      # ядро
docker compose --profile torrents up -d   # плюс торренты, если нужны

Прежде чем что-то качать — убедимся, что качалки реально ходят через VPN, а не мимо. Спрашиваем публичный IP изнутри туннеля (из контейнера, который в netns gluetun):

docker exec qbittorrent sh -c 'wget -qO- https://ipinfo.io/ip'
# → адрес вашего VPN-сервера. С реальным IP хоста совпасть НЕ должен.

Это принципиально: спросив IP с хоста, вы увидите egress хоста и ничего не поймёте. Проверять надо именно из namespace VPN. Убьёте туннель (docker stop gluetun) — у slskd/qbit пропадёт сеть целиком: killswitch fail-closed, утечь нечему.

Шаг 6. Автозапуск после ребута

Чтобы стек поднимался сам, вешаем на хост CT systemd-юнит:

# /etc/systemd/system/music-app.service  (под root)
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/home/<user>/music-app
ExecStart=/usr/bin/docker compose --profile torrents up -d
User=<user>
[Install]
WantedBy=multi-user.target

systemctl enable --now music-app. Вместе с restart: unless-stopped на контейнерах и onboot=1 на самом LXC — три уровня «поднимись сам», потому что за «после ребута ничего не играет» никто не любит просыпаться.

Настройка сервисов

Compose поднял контейнеры — теперь пройдёмся по веб-мордам и включим то, что не задаётся через окружение. Никаких ручных запросов к API: всё это тумблеры и поля в интерфейсах (но я это почти все не нажимал, только юзеров создавал, я попросил клода и он через API все сам нажал, рекомендую, а потом я его же попросил написать кнопки в текст статьи, они примерные).

Navidrome (nvd.example.com). При первом входе создаём администратора. Библиотека уже примонтирована (/music), скан идёт по расписанию. Для sonic-похожести нужен плагин AudioMuse: кладём файл audiomuseai.ndp в navidrome/plugins/ и в его настройках указываем адрес AudioMuse (http://audiomuse-ai-flask-app:8000). В compose агент уже включён первым (ND_AGENTS=audiomuseai,deezer) — этого достаточно, чтобы Navidrome начал отдавать «похожие треки».

Soulseek (slskd.example.com). Аккаунт Soulseek заводится сам фактом первого логина с логином/паролем из .env. Проверяем, что демон подключился и что открыт порт раздачи — без входящего порта вы только качаете, но не раздаёте, а на Soulseek это влияет на репутацию.

SoulSync (ss.example.com) — центральный пульт. Здесь всё через интерфейс:

  • Источники. Включаем нужные: Soulseek (подхватывается автоматически), YouTube (работает из коробки, качает через VPN-прокси). Для торрентов в настройках указываем адреса нашего Prowlarr и qBittorrent — SoulSync будет искать и там.

  • Профиль качества. Ставим режим best_quality (пулит кандидатов со всех источников и берёт лучший по факту, а не первый), cutoff — FLAC 16-bit, и включаем replace lower quality (замена файла на лучший на месте).

  • Обслуживание библиотеки (repair-джобы). Включаем те, что чинят метадату скачанного: Unknown Artist Fixer, Metadata Gap Filler (дотягивает MBID из MusicBrainz), Fake Lossless Detector (спектрально ловит псевдо-FLAC), Quality Upgrade (движок апгрейда до FLAC). Про важный нюанс их применения — в разделе «как работает».

  • Раскладка. Включаем file organization (Артист/Альбом/Трек) и отмечаем /music как постоянную библиотеку — тогда SoulSync пишет прямо в неё и не пытается растащить уже разложенное.

  • Телефон. SoulSync — это PWA: открываем ss.example.com в мобильном браузере и добавляем на домашний экран. Дальше поиск и скачивание — прямо из кармана. Интерфейс там так себе, но привыкнуть можно, а мне надо раз в месяц.

AudioMuse (audiomuse.example.com). Проходим setup-wizard (указываем Navidrome и креды), запускаем первичный анализ библиотеки — воркер посчитает звуковые эмбеддинги, это разово и небыстро. Похожесть работает только по проанализированным трекам, так что дайте анализу закончиться. Чтобы плейлисты обновлялись сами — на странице Cron заводим две ночные задачи: analysis (проанализировать новьё) и clustering (пересобрать sonic-плейлисты и записать их прямо в Navidrome). После этого никаких действий от вас не требуется — плейлисты появляются в Symfonium как обычные.

Explo. Указываем токен ListenBrainz, задаём расписание — раз в неделю он собирает рекомендацию «по вкусу» и кладёт плейлистом в Navidrome.

Повторю, я только в navidrome пользователя создал и попросил нейронку все через API сервисов настроить, она сама все и настроила за пару итерация проверок, что работает.

Как это работает

SoulSync: от «услышал» до «играет»

Вы ищете трек в PWA, SoulSync опрашивает источники и в режиме best_quality берёт лучшую версию, скачивает её, тегирует и раскладывает в Артист/Альбом/Трек прямо в /music. Через минуту Navidrome его проиндексировал — и он играет в наушниках.

Тонкость, которую стоит знать: у SoulSync два пути добавления. Основной, через поиск-и-подтверждение в интерфейсе, приносит трек с полными тегами, обложкой и текстом. Есть и «сырой» путь (скачать по прямой ссылке) — он кладёт файл без обогащения, плоско. Спасает то, что Navidrome браузит по тегам, а не по папкам: даже плоский-но-тегированный файл виден в библиотеке правильно, а безтеговые дочищают repair-джобы поиском по названию (джобы в soulsync многое могут).

Источники под капотом: YouTube и торрентная связка

YouTube — источник «по умолчанию», настройки не требует вовсе. SoulSync ищет прямо по YouTube, качает лучшую аудиодорожку (на выходе .opus, обычно ~130–140 kbps) и весь этот трафик гонит через HTTP-прокси gluetun — в compose выше это уже задано переменными HTTP_PROXY/HTTPS_PROXY, со своего IP SoulSync наружу не ходит. Дальше обычный конвейер: теги и обложка из метадата-источника, раскладка в /music. Полезная деталь — встроенная проверка целостности: длительность скачанного сверяется с ожидаемой (допуск порядка 15 секунд), и «не то» отбрасывается. Это спасает от классической подставы YouTube-поиска, когда вместо студийной версии прилетает лайв, ремикс или десятиминутный «full album» — я на этом допуске живьём видел, как отсеялся файл с дрейфом в 31 секунду: пришла другая редакция трека, и система её честно не взяла.

По качеству без иллюзий: opus с YouTube — это пол, а не потолок. Тут у SoulSync два поведения, и их стоит различать. Простое «скачай сейчас» идёт по цепочке приоритетов источников — это настройка, у меня youtube → soulseek → torrent (YouTube первым: отвечает быстрее всех, для «услышал — добавил» это то, что нужно). А вот автодобор — wishlist, апгрейд качества — собирает кандидатов со всех включённых источников разом и берёт лучшего по качеству: нашёлся FLAC на Soulseek или торрентах — возьмёт его, не нашёлся — YouTube закроет дырку хоть чем-то. Поэтому Soulseek стоит держать включённым, даже если кажется, что «всё равно всё едет с ютуба».

Торренты — это профиль torrents из compose и связка трёх ролей: Prowlarr — «адресная книга» трекеров, qBittorrent — качалка, SoulSync — заказчик. Первая пара живёт в сетевом namespace gluetun (network_mode: service:gluetun), и отсюда неочевидное следствие: у этих контейнеров нет собственного сетевого имени, их веб-морды и API видны по имени VPN-шлюза. То есть в интеграциях указывается не prowlarr:9696, а http://gluetun:9696 и http://gluetun:8080 — это второе после docker-DNS место (грабля №1), где namespace ломает интуицию, закладывайте.

Настройка руками (или инструктажем ИИ — все три пункта делаются через их API):

  1. Prowlarr (:9696). Добавляем индексаторы: открытые трекеры заводятся логином/паролем — Prowlarr сам логинится и держит сессию; полузакрытые — пасскеем из профиля на трекере. Тут же берём Settings → General → API Key, он понадобится SoulSync.

  2. qBittorrent (:8080). Здесь ровно одно действие: задать постоянный пароль (грабля №4: временный слетает на рестарте — и интеграция молча отваливается). Каталог загрузок в самом qBit настраивать не нужно — SoulSync передаёт свой save_path с каждой раздачей; важно лишь, чтобы у обоих контейнеров был примонтирован один и тот же ./data (в compose выше так и есть).

  3. SoulSync → Settings: адрес Prowlarr + его API key, адрес qBittorrent + логин/пароль (оба адреса — через gluetun:, см. выше), путь загрузок /data/torrents и категория (у меня soulsync — по ней закачки SoulSync отличимы от всего остального в qBit). Тест-кнопки должны позеленеть.

Как идёт закачка целиком: SoulSync спрашивает Prowlarr → тот веером опрашивает все заведённые трекеры и возвращает раздачи с сидами → SoulSync выбирает подходящую и отдаёт её qBittorrent с категорией soulsync и путём /data/torrents → qBit качает через туннель → SoulSync видит завершение, вынимает из раздачи нужные файлы, тегирует и раскладывает в /music. Раздачу, которая не двигается (нулевые сиды), SoulSync бросает по stall-таймауту — у меня 10 минут, — так что мёртвые торренты не висят в очереди вечно. Специфика музыкальных торрентов, о которую спотыкаешься сразу: раздача — почти всегда альбом или дискография, а не отдельный трек. Для «услышал — добавил» этот канал избыточен (там отрабатывают Soulseek и YouTube), зато для FLAC-альбомов и редкого русского каталога он основной — тот самый добор «мёртвых» из главы про миграцию ехал именно здесь.

Проверить, что цепочка жива, можно одним запросом к Prowlarr, не тыкая интерфейсы (3000 — категория «музыка»):

curl -s -H "X-Api-Key: $PROWLARR_KEY" \
  'http://localhost:9696/api/v1/search?query=<артист>&categories=3000'
# → JSON с релизами и числом сидов. Релизы пришли — трекеры отвечают, дальше дело qBit.

FLAC-апгрейд на месте

Quality Upgrade — это движок повышения качества. Раз в неделю он читает каждый файл, сравнивает с профилем (cutoff = FLAC), для трека ниже профиля ищет лучшую версию и показывает находку. Подтверждаете — трек качается из общего пула источников, и включённый replace lower quality заменяет старый файл по тому же пути; Navidrome пересканирует, дублей нет.

Важно, почему это ручное подтверждение, а не автомат: AcoustID и матчинг по названию регулярно путают каверы, лайв и ремиксы. Живой пример из практики — «Die Antwoord — Baby’s on Fire» опознаётся по звуку как «Goat». Автозамена «на похожее» тихо угробит библиотеку неправильными версиями. Поэтому машина ищет — человек подтверждает. Тот же принцип у всех джобов, которые могут подменить файл: они по умолчанию только показывают находки, применяете вы.

Но и тут я просто попросил ИИ через API все самому разгрести, а то там поначалу были какие-то тысячи находок. Сам я пару песен искал, нашел, которые нужны были и скачал, а все разгребание это puthon скрипты, которые ИИ себе написал и сам все сделал. Тратить на такую ерунду жизнь смысла нет больше.

Sonic-рекомендации на телефоне

AudioMuse анализирует звук (Essentia + CLAP-эмбеддинги), а не теги, — поэтому находит похожее даже для обскура, которого нет ни в одной метадата-базе. Плагин audiomuseai.ndp добавляет Navidrome стандартное OpenSubsonic-расширение sonic-похожести, и оно уезжает на телефон.

В Symfonium это оживает как Radio Mix «похожее по звуку» — в настройках клиента включается опция использовать серверную похожесть для радио (иначе радио крутит по артисту, а не по звуку). А вот «умные потоки» Smart Flow (режимы вроде Echo Match) для Subsonic-сервера требуют более свежей версии Symfonium — там завезли поддержку нужного расширения; на более старой они серые. Сервер отдаёт всё — упирается в клиент. Ставьте бэту 15.0 и будет работать.

Второй слой рекомендаций — Explo с ListenBrainz — работает по другой оси: не «похожее по звуку из того, что есть», а «чего у тебя нет» по коллаборативному вкусу. Но я пока проверил, что прослушанное правда уезжает в ListenBraiz, рекомендации оно вроде как генерит раз в неделю, проверю еще.

Удаление трека с телефона

Symfonium не умеет слать серверу команду «удали». Обходим просто: заводим на сервере плейлист «Удалить», а на хосте — таймер, который раз в 10 минут переносит помеченные треки в корзину (обратимо, с манифестом). Кинул трек в плейлист с телефона — через несколько минут он уехал из библиотеки, но не пропал безвозвратно. Скрипт тоже написал ИИ, я проверил, что работает.

Миграция и добор «мёртвых» треков

Отдельный сюжет — как перетащить коллекцию из YTM и вернуть удалённое. Из моей миграции осталось 487 «мёртвых» треков — тех, что Google уже удалил (unavailable/copyright), преимущественно русское и нишевое. Первое, что нужно сделать, — зафиксировать их намертво списком (videoId, артист, название), пока не потерялись окончательно; этот список потом не трогаем.

Soulseek по такому нишевому вывозит процентов двадцать, поэтому основным каналом добора стали торренты — открытые и полу-закрытые трекеры через тот же Prowlarr+qBit под VPN. Тонкость русских трекеров: они индексят дискографиями, а не треками, поэтому искать надо по артисту, тянуть архив, вынимать из него нужные треки по совпадению имени файла и удалять балласт — диск не резиновый.

Часть «мёртвых» была голыми videoId без названия. Их можно опознать через публичный YouTube oEmbed — он отдаёт автора и заголовок ролика без единой куки, по одному videoId. То, чего не нашлось на торрентах, добирается YouTube-альтернативами через SoulSync. В сумме у меня вернулось около 372 из 487.

Дальше чистка. Скачанное с YouTube приходит без тегов, и [Unknown Artist] быстро расплодился. Чинить это надо по звуку: AcoustID-фингерпринт опознаёт запись по фактическому содержимому файла, а не по кривому заголовку вида «ARTIST - Title (Official Video) [4K]» — матчинг по такому заголовку ушёл бы не туда. Плюс дедуп по фингерпринту (один трек в разных качествах → оставить лучшее). Честный потолок: то, чего нет ни во FLAC, ни в альтернативной заливке нигде, останется в opus или потеряется — самохостинг не печатает записи, которых не существует.

Главное конечно, а где все это взять? Плейлисты то где? Это в takeout google. Я там достал не только плейлисты, но и всю свою активность начиная с 2009 года, по которой восстановил где и чем я занимался вплоть до дней, включая геолокацию с точностью до города, но это другая история. По активности можно достать и что я слушал, потому, что в плейлистах Гугла останется только то, что он не удалил, а в активности есть все. Я просто попросил ИИ провести анализ и он нарыл ~600 треков из плейлистов и ~2500 треков по косвенным данным активности на “сайте гугла”, а на деле YTM YT.

Плейлисты из Google Takeout — по шагам

Takeout — это исходник, но из коробки он даёт не плейлисты, а сырьё. Покажу конкретно, как превратить его в рабочие плейлисты в своём стеке, на тех же ручках, что и всё остальное. Сам я это, понятно, руками не собирал: скинул ИИ папку выгрузки и задачу, он написал пачку python-скриптов и прогнал их через API SoulSync и Navidrome прямо в контейнере. Ниже — механика, чтобы повторить (или чтобы отдать этот раздел своему Клоду и не объяснять на пальцах).

Что внутри Takeout. Заказываете выгрузку «YouTube and YouTube Music». Плейлисты лежат в Takeout/YouTube and YouTube Music/playlists/ — по одному CSV на плейлист (лайки — отдельным файлом). Нюанс, который экономит полдня недоумения: в CSV только videoId и метка времени, без названий. Гугл отдаёт голые идентификаторы, названия достаём сами. И то же, что уже сказано выше про мёртвых: в плейлистах Takeout остаётся лишь то, что Гугл ещё не удалил; полную картину «что я вообще слушал» даёт Takeout/My Activity/YouTube/ (там сразу с заголовками), но это шумный источник — туда падает вообще всё.

Шаг 1. videoId → артист и название. По голому id тянем метаданные публичным YouTube oEmbed — без единой куки, по одному id:

curl -s 'https://www.youtube.com/oembed?url=https://youtu.be/<videoId>&format=json'
# → {"title":"...","author_name":"Аквариум - Topic", ...}

Канал вида «Артист - Topic» — официальная музыкальная заливка, author_name из него даёт чистого артиста; для обычных каналов заголовок парсим по «Артист — Название». oEmbed вернул 404 — трек Гугл удалил, это «мёртвый» на добор с торрентов (см. выше).

Шаг 2. Разделяем: что уже в библиотеке, что добирать. Для каждой пары (артист, название) спрашиваем Navidrome субсоник-поиском, есть ли трек. Токен субсоника — md5(пароль + соль), считаем его локально, наружу уходит только хеш, не пароль:

curl -s -H 'Host: nvd.music' \
  'http://localhost/rest/search3?query=Аквариум+Город+золотой&u=<user>&t=<md5>&s=<salt>&v=1.16.1&c=cc&f=json'
# song[].id в выдаче → трек в библиотеке; пусто → в добор

song.id из выдачи — это и есть идентификатор для плейлиста.

Шаг 3. Недостающее — в добор через SoulSync. Прогоняем через тот же движок, что наполняет всю библиотеку: сперва резолвим кандидата на Deezer (нужны точные длительность и альбом — страховка от «не той версии»), кладём в wishlist полной карточкой, запускаем обработку. SoulSync качает в лучшем качестве и раскладывает в /music:

# найти кандидата на Deezer (ручка SoulSync, ключ не нужен)
curl -s -H 'Host: ss.music' --data-urlencode 'query=Аквариум Город золотой' \
  -G 'http://localhost/api/deezer/search_tracks'
# → tracks[].id / name / artists / duration_ms

# положить в wishlist полной карточкой (id, имя, артист, альбом, длительность)
# $SS_KEY — разовый ключ SoulSync API из его настроек
curl -s -H 'Host: ss.music' -H "Authorization: Bearer $SS_KEY" \
  -H 'Content-Type: application/json' -X POST 'http://localhost/api/v1/wishlist' \
  -d '{"track_data":{"id":"deezer_123","name":"Город золотой","artists":[{"name":"Аквариум"}],"album":{"name":"…"},"duration_ms":222000},"source_type":"manual"}'

# запустить обработку (авто-обработка wishlist и так висит по расписанию, но можно пнуть)
curl -s -H 'Host: ss.music' -H "Authorization: Bearer $SS_KEY" -X POST \
  'http://localhost/api/v1/wishlist/process'

Место, где легко налажать на пакетном доборе, — сверка длительности. Deezer по «артист + название» охотно отдаёт ремикс, лайв или кавер той же длины плюс-минус пара минут; добавишь вслепую — в плейлист приедет не та версия. Берём кандидата, только если имя совпало И длительность в пределах ±5–6 секунд, остальное — на ручную сверку. Тот же принцип «машина ищет — человек подтверждает», что и у апгрейда качества.

Шаг 4. Собираем плейлист в Navidrome. Когда добор осел и Navidrome пересканировал /music (новым трекам присвоены id), собираем плейлист субсоник-ручкой createPlaylist — имя и список songId:

curl -s -H 'Host: nvd.music' \
  'http://localhost/rest/createPlaylist?name=Мой+перенос&songId=<id1>&songId=<id2>&u=<user>&t=<md5>&s=<salt>&v=1.16.1&c=cc&f=json'

Грабля напоследок: songId, которых нет в текущем индексе Navidrome, ручка молча пропускает. Если songCount в ответе меньше, чем вы слали, — часть треков ещё не доехала из добора или не нашлась вовсе; дособрать позже той же updatePlaylist.

Как это в жизни. Я отдал ИИ папку playlists/ и сказал «собери в Navidrome, чего нет — в добор». Он распарсил CSV, прогнал id через oEmbed, разделил «есть/нет», недостающее с проверкой длительности покидал в wishlist, а из имеющегося собрал плейлист; мёртвое (oEmbed 404) — отдельным списком на торренты. Всё теми же ручками, что выше. Повторить с другим Клодом — дать ему этот раздел и путь к папке Takeout, дальше он допишет скрипты сам.

Грабли

Места, где я потратил время, чтобы вы не потратили.

  1. Сетевая ловушка namespace. Контейнер за network_mode: service:gluetun теряет docker-DNS и не видит соседей по имени. Поэтому в туннель сажаем только качалки, а SoulSync держим в обычной сети с выходом через прокси VPN. В compose выше это уже разведено.

  2. root-owned каталоги и тихо дохнущие сервисы. Всё крутится под uid 1000; если какой-то каталог остался root-owned от прежнего запуска, systemd-таймеры под обычным пользователем молча падают — юнит зелёный, работа не делается. Если «настроено, но не срабатывает» — первым делом проверяйте владельца каталогов, а не логику.

  3. AcoustID путает каверы. Поэтому все операции, которые могут подменить файл (апгрейд качества, ретег по звуку), — на ручном подтверждении. Не автоматизируйте, каким бы заманчивым это ни было. Но я автоматизировал, это всего лишь музыка :)

  4. Пароль qBittorrent слетает на рестарте (образ генерит временный при старте) — интеграция с SoulSync отваливается. Лечится тем, что после первого входа задаёте постоянный пароль в веб-морде qBit, тогда хэш ложится в конфиг и переживает рестарт. Делал ИИ.

  5. Killswitch проверяйте IP изнутри namespace, а не с хоста — иначе увидите egress хоста и сделаете неверный вывод об утечке.

  6. Пин версии SoulSync. Проект очень активный, релизы частые; держите конкретный проверенный тег, а не latest, — ломающий апдейт прилетит ровно тогда, когда раз в неделю зайдёте что-то скачать.

Что в итоге

  • Своё ядро. Navidrome отдаёт мою коллекцию по OpenSubsonic, клиент — Symfonium через mTLS. Стек переживает ребут и не требует няньки в обычный день.

  • Добавление с телефона. SoulSync-PWA: услышал → нашёл → скачал в лучшем качестве → легло в библиотеку с тегами и обложкой.

  • Приватность проверена, а не заявлена. Внешнее приобретение — через VPN; Soulseek-P2P в netns с fail-closed killswitch; домашний IP не светится, наружу только :443 за mTLS.

  • Коллекция наполнена и курируется. Теги причёсаны, обслуживание — на repair-джобах, а не ручном труде.

  • Рекомендации замкнуты. Explo/ListenBrainz даёт «чего нет», AudioMuse — «похожее по звуку»; sonic доехал до телефона, плейлисты пересобираются каждую ночь сами.

И всё это zero-sub — ни одной активной подписки. Если бы они не хозяйничали в моих плейлистах я так бы и платил, но теперь с ИИ, он настроил все это за три вечера пока я его ругал, что все не так. Самое сложное было подобрать хорошо притертые сервисы и удобные что бы на телефоне было два приложения, одно для поиска (к сожалению PWA лучшее, что найдено) и одно играть. При этом ИИ сам лазит в SoulSync и другие сервисы, я просто в чате пишу “подними качество” или что там у нас по статусу джобов, лазить в PWA SoulSync или еще куда мне не хочется, а в Symfonium на телефоне просто есть огромная коллекция музыки с рекомендациями и просто работает. Как заработало, я так на ковыряниеяи и забил. Symfonium, кстити, не забудьте включить кэширование, что бы задержек не было, оно там выключено по умолчанию. Все вместе даже умные плейлисты строит по спектральному анализу и прочее подобное, все работает и притерто.

P.S. Всем хорошей музыки.