Compare commits

..

10 commits

Author SHA1 Message Date
b5b6e99946
fix(lavalink): add TV client, the only OAuth-compatible youtube-plugin client
Some checks are pending
CI / build (push) Waiting to run
Docker / build (push) Waiting to run
2026-09-11 12:10:07 +02:00
084337c433
fix(lavalink): persist youtube OAuth refresh token via env var 2026-09-11 12:06:57 +02:00
7e941ba9b3
fix(lavalink): enable youtube-plugin OAuth to bypass 'requires login' errors 2026-09-11 11:59:52 +02:00
affdb684b9
fix(music): add lavasrc-patcher sidecar for auto-applying runtime config
LavaSrc plugin config (preferPartnerApi, customTokenEndpoint, spDc)
doesn't bind from YAML in Lavalink 4. Added a one-shot sidecar container
that applies the config via PATCH endpoint after Lavalink becomes healthy.
Also cleaned up DEBUG logging.
2026-08-31 16:22:14 +02:00
af88cea301
fix(music): add spotify-tokener with Chromium for Partner API anonymous tokens
Spotify changed their API in Feb 2026 - playlists now require anonymous
tokens via the Partner API. The default anonymous token endpoint is
blocked from server IPs. Added a custom Dockerfile for spotify-tokener
with Chromium installed, and configured LavaSrc to use Partner API with
the tokener endpoint. Playlists now load successfully.
2026-08-31 16:19:30 +02:00
9aebef15e1
fix(lavalink): add PLUGINS_LAVASRC_SPOTIFY_PREFERPARTNERAPI env var 2026-08-31 15:36:58 +02:00
306dc01cd1
chore(lavalink): enable DEBUG logging for lavasrc 2026-08-31 15:27:48 +02:00
4092ad25f7
fix(lavalink): enable Spotify Partner API to bypass playlist restriction
Spotify Feb 2026 API change: /playlists/{id}/items now requires user auth.
Partner API (GraphQL) bypasses this for public playlists.
2026-08-31 15:22:27 +02:00
ad17cff40e
fix(music): add Lavalink health-check with auto-reconnect
- Add 30s scheduled health-check that detects unavailable nodes
- Remove and re-add node when it becomes unavailable (fixes WS disconnect)
- Log WebSocketClosedEvent for debugging connection drops
2026-08-31 14:54:15 +02:00
520dccbafe
docs(todo): record 2026-08-30 bug-hunt audit findings (39 items, unfixed) 2026-08-30 00:33:09 +02:00
8 changed files with 112 additions and 4 deletions

View file

@ -126,6 +126,7 @@
| M12 | 🟢 **LOW** | `m!play` игнорирует неудачу подключения — «Добавлено в очередь», а звука нет | `MusicBotPlayHandler.java:36` |
| M13 | 🟢 **LOW** | `destroy()` глотает `InterruptedException` без восстановления флага | `GuildMusicManager.java:64-66` |
| M14 | 🟢 **LOW** | `r!volume` рапортует успех при отсутствии радио-сессии | `RadioSubcommands.java:108-113` |
| M15 | 🟡 **MEDIUM** | Spotify-плейлисты не грузятся: Spotify (политика 27.11.2024) требует user-auth для `/v1/playlists/*` — client credentials получают `401 Valid user authentication required`. Одиночные треки работают (метаданные по client credentials + плейбек через scsearch-мирроринг). Варианты: OAuth refresh token юзера с scope `playlist-read-private`, либо revival browser-tokener | `lavalink/application.yml` (lavasrc) |
#### Core / database

View file

@ -1,8 +1,19 @@
services:
spotify-tokener:
build:
context: ./lavalink
dockerfile: Dockerfile.tokener
container_name: spotify-tokener
restart: unless-stopped
environment:
- SPOTIFY_TOKENER_CHROME_PATH=/usr/bin/chromium
lavalink:
image: ghcr.io/lavalink-devs/lavalink:4-alpine
container_name: loki-lavalink
restart: unless-stopped
depends_on:
- spotify-tokener
ports:
- "127.0.0.1:2333:2333"
volumes:
@ -13,6 +24,7 @@ services:
JAVA_OPTS: "-Xmx1G -Xms256M"
SPOTIFY_CLIENT_ID: ${SPOTIFY_CLIENT_ID}
SPOTIFY_CLIENT_SECRET: ${SPOTIFY_CLIENT_SECRET}
YOUTUBE_OAUTH_REFRESH_TOKEN: ${YOUTUBE_OAUTH_REFRESH_TOKEN}
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null --header=Authorization:Hch4HiVCxkKs9JPiSnrt4Q http://localhost:2333/v4/info || exit 1"]
interval: 30s
@ -20,6 +32,16 @@ services:
retries: 3
start_period: 20s
lavasrc-patcher:
build:
context: ./lavalink
dockerfile: Dockerfile.config-patcher
container_name: lavasrc-patcher
depends_on:
lavalink:
condition: service_healthy
restart: "no"
bot:
build: .
container_name: loki-bot

View file

@ -0,0 +1,5 @@
FROM alpine:3.20
RUN apk add --no-cache curl
COPY apply-config.sh /apply-config.sh
RUN chmod +x /apply-config.sh
CMD ["/apply-config.sh"]

View file

@ -0,0 +1,6 @@
FROM ghcr.io/topi314/spotify-tokener:master
RUN apt-get update && \
apt-get install -y --no-install-recommends chromium && \
rm -rf /var/lib/apt/lists/*
ENV SPOTIFY_TOKENER_CHROME_PATH=/usr/bin/chromium
ENV CHROMIUM_FLAGS="--no-sandbox --disable-gpu --headless"

View file

@ -6,6 +6,7 @@ plugins:
youtube:
enabled: true
clients:
- TV
- MUSIC
- ANDROID_VR
- WEB
@ -13,6 +14,9 @@ plugins:
remoteCipher:
url: "https://cipher.kikkia.dev/"
userAgent: "loki-bot"
oauth:
enabled: true
refreshToken: "${YOUTUBE_OAUTH_REFRESH_TOKEN}"
lavasrc:
providers:
- "scsearch:%ISRC%"
@ -25,9 +29,12 @@ plugins:
spotify:
clientId: "${SPOTIFY_CLIENT_ID}"
clientSecret: "${SPOTIFY_CLIENT_SECRET}"
spDc: "AQDVrLVOyLLT3ZuiLoY32uH8HotvY_Lw67ay1lejf55S7O3f_O9IefxZ8XDf3pYklpwl5Klei2NeYUPRZ-cbkpQg5S3RyMjuiTmBxdAuMlugA61_wAy-bXD979aYDK44Bid6UKxLHjh0kFQZ_tX71DAUGezA2XjeLcRi_-TLcKHWKZVKPhGniUOZZEhg2Ij9t6valbqSbDOQ1t0dtQ"
countryCode: "RU"
playlistLoadLimit: 10
albumLoadLimit: 10
preferPartnerApi: true
customTokenEndpoint: "http://spotify-tokener:8080/api/token"
applemusic:
countryCode: "RU"
mediaType: "music"
@ -54,3 +61,4 @@ logging:
level:
root: INFO
lavalink: INFO
com.github.topi314.lavasrc: INFO

16
lavalink/apply-config.sh Normal file
View file

@ -0,0 +1,16 @@
#!/bin/sh
# Apply LavaSrc runtime config after Lavalink starts
until curl -sf -H "Authorization: Hch4HiVCxkKs9JPiSnrt4Q" http://lavalink:2333/v4/info >/dev/null 2>&1; do
sleep 2
done
sleep 5
curl -sf -X PATCH \
-H "Authorization: Hch4HiVCxkKs9JPiSnrt4Q" \
-H "Content-Type: application/json" \
-d '{"spotify":{"preferPartnerApi":true,"customTokenEndpoint":"http://spotify-tokener:8080/api/token","spDc":"AQDVrLVOyLLT3ZuiLoY32uH8HotvY_Lw67ay1lejf55S7O3f_O9IefxZ8XDf3pYklpwl5Klei2NeYUPRZ-cbkpQg5S3RyMjuiTmBxdAuMlugA61_wAy-bXD979aYDK44Bid6UKxLHjh0kFQZ_tX71DAUGezA2XjeLcRi_-TLcKHWKZVKPhGniUOZZEhg2Ij9t6valbqSbDOQ1t0dtQ"}}' \
http://lavalink:2333/v4/lavasrc/config
echo "LavaSrc config applied"

View file

@ -9,6 +9,7 @@ import dev.arbjerg.lavalink.client.event.ReadyEvent;
import dev.arbjerg.lavalink.client.event.StatsEvent;
import dev.arbjerg.lavalink.client.event.TrackEndEvent;
import dev.arbjerg.lavalink.client.event.TrackStartEvent;
import dev.arbjerg.lavalink.client.event.WebSocketClosedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -65,5 +66,18 @@ class LavalinkEventRouter {
manager.getScheduler().onTrackEnd(event.getTrack(), event.getEndReason());
}
});
client
.on(WebSocketClosedEvent.class)
.subscribe(
event ->
LoggerDecorator.music(
logger,
"WebSocket закрыт: code="
+ event.getCode()
+ ", reason="
+ event.getReason()
+ ", byRemote="
+ event.getByRemote()));
}
}

View file

@ -2,6 +2,9 @@ package creator.loki.feature.music.core;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.LongConsumer;
import com.google.inject.Inject;
@ -27,6 +30,13 @@ public class LavalinkManager {
private final BotConfig config;
private final QueueRepository queueRepository;
private volatile LongConsumer voiceDisconnectHandler;
private final ScheduledExecutorService healthCheck =
Executors.newSingleThreadScheduledExecutor(
r -> {
Thread t = new Thread(r, "lavalink-health-check");
t.setDaemon(true);
return t;
});
@Inject
public LavalinkManager(BotConfig config, QueueRepository queueRepository) {
@ -46,6 +56,17 @@ public class LavalinkManager {
this.client = new LavalinkClient(Helpers.getUserIdFromToken(botToken));
addNode(lavalinkConfig);
new LavalinkEventRouter(client, musicManagers).register();
healthCheck.scheduleWithFixedDelay(this::checkNodeHealth, 30, 30, TimeUnit.SECONDS);
LoggerDecorator.music(
logger, "LavalinkManager инициализирован, нода: " + lavalinkConfig.getWsUri());
}
private void addNode(LavalinkConfig lavalinkConfig) {
client.addNode(
new NodeOptions.Builder()
.setName("main")
@ -53,11 +74,26 @@ public class LavalinkManager {
.setPassword(lavalinkConfig.getPassword())
.setHttpTimeout(30000L)
.build());
}
new LavalinkEventRouter(client, musicManagers).register();
LoggerDecorator.music(
logger, "LavalinkManager инициализирован, нода: " + lavalinkConfig.getWsUri());
private void checkNodeHealth() {
try {
var nodes = client.getNodes();
if (nodes.isEmpty()) {
logger.warn("Lavalink: нет нод, пересоздаю");
addNode(config.getLavalinkConfig());
return;
}
var node = nodes.get(0);
if (!node.getAvailable()) {
logger.warn("Lavalink нода '{}' недоступна, переподключаю", node.getName());
client.removeNode(node);
addNode(config.getLavalinkConfig());
LoggerDecorator.music(logger, "Lavalink нода переподключена");
}
} catch (Exception e) {
logger.error("Ошибка health-check Lavalink", e);
}
}
public LavalinkClient getClient() {