Личное облако на Proxmox: как собрать себе музыкальный стриминговый сервис

от автора

Это руководство для того, кто решил поднять всё сам. От пустого 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/configmkdir -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)"} >> .envchmod 600 .env

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

read -rs WGKEYprintf '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: musicnetworks:  default:    name: music    ipam:      config:        - subnet: 172.28.0.0/24   # фиксируем подсеть, чтобы не пересечься с диапазоном VPNx-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,slskd.music,caddy,172.28.0.0/16,.music  # внутреннее → напрямую    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=oneshotRemainAfterExit=yesWorkingDirectory=/home/<user>/music-appExecStart=/usr/bin/docker compose --profile torrents up -dUser=<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 многое могут).

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.

Грабли

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

  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. Всем хорошей музыки.

ссылка на оригинал статьи https://habr.com/ru/articles/1060002/