From 2d06513bfbb0de2b697a4eb5528b76f1ca0466b8 Mon Sep 17 00:00:00 2001 From: kuroneko6423 Date: Sun, 26 Apr 2026 23:58:31 +0900 Subject: [PATCH 1/2] =?UTF-8?q?ytdlp=E3=81=AB=E3=81=97=E3=82=88=E3=81=86?= =?UTF-8?q?=E3=81=AE=E4=BC=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/websocket/MediaController.kt | 28 +- .../bot/audio/media/PlayListHelper.kt | 6 +- .../bot/audio/media/ResponseMediaData.kt | 40 +-- .../lunaproject/bot/audio/media/TrackData.kt | 45 ++- .../bot/audio/media/YtDlpAudioResolver.kt | 258 ++++++++++++++++++ .../bot/commands/message/media/Play.kt | 31 +++ .../bot/commands/slash/media/Play.kt | 31 +++ .../bot/components/commands/media/Play.kt | 8 +- .../bot/components/commands/media/PlayList.kt | 26 +- .../components/constants/MediaComponent.kt | 10 +- .../bot/configurations/BotConfig.kt | 19 +- src/main/resources/config.yml | 18 ++ 12 files changed, 470 insertions(+), 50 deletions(-) create mode 100644 src/main/kotlin/jp/lunaproject/bot/audio/media/YtDlpAudioResolver.kt diff --git a/src/main/kotlin/jp/lunaproject/bot/api/restful/controllers/websocket/MediaController.kt b/src/main/kotlin/jp/lunaproject/bot/api/restful/controllers/websocket/MediaController.kt index 6d6900ec..33095319 100644 --- a/src/main/kotlin/jp/lunaproject/bot/api/restful/controllers/websocket/MediaController.kt +++ b/src/main/kotlin/jp/lunaproject/bot/api/restful/controllers/websocket/MediaController.kt @@ -14,6 +14,7 @@ import jp.lunaproject.bot.audio.media.MediaController import jp.lunaproject.bot.audio.media.RequestMediaData import jp.lunaproject.bot.audio.media.ResponseMediaData import jp.lunaproject.bot.audio.media.TrackData +import jp.lunaproject.bot.audio.media.YtDlpAudioResolver import jp.lunaproject.bot.utils.language.LanguageUtil import jp.lunaproject.bot.utils.objects.JSONUtil import net.dv8tion.jda.api.entities.User @@ -92,13 +93,36 @@ suspend fun action(connection: Connection, rawData: String) { is RequestMediaData.Play -> { val keyword = data.keyword + + when (val resolved = YtDlpAudioResolver.resolve(keyword, controller, member)) { + is YtDlpAudioResolver.LoadResult.Track -> { + controller.play(resolved.track) + session.send(ResponseMediaData.PlayTrack(ResponseMediaData.TrackData.of(resolved.track))) + return + } + + is YtDlpAudioResolver.LoadResult.Playlist -> { + controller.play(resolved.tracks) + + session.send( + ResponseMediaData.PlayTracks( + resolved.name, + resolved.tracks.map { ResponseMediaData.TrackData.of(it) } + ) + ) + return + } + + null -> Unit + } + val res = controller.link.loadItem(if (keyword.matches(_urlRegex)) keyword else "ytsearch: $keyword") when (res.loadType) { TrackResponse.LoadType.TRACK_LOADED -> { val track = TrackData(res.track.toTrack(), member) controller.play(track) - session.send(ResponseMediaData.PlayTrack(ResponseMediaData.TrackData.of(track.track))) + session.send(ResponseMediaData.PlayTrack(ResponseMediaData.TrackData.of(track))) return } @@ -110,7 +134,7 @@ suspend fun action(connection: Connection, rawData: String) { session.send( ResponseMediaData.PlayTracks( playlist.name, - tracks.map { ResponseMediaData.TrackData.of(it.track) } + tracks.map { ResponseMediaData.TrackData.of(it) } ) ) } diff --git a/src/main/kotlin/jp/lunaproject/bot/audio/media/PlayListHelper.kt b/src/main/kotlin/jp/lunaproject/bot/audio/media/PlayListHelper.kt index 22f6cfc2..1af74749 100644 --- a/src/main/kotlin/jp/lunaproject/bot/audio/media/PlayListHelper.kt +++ b/src/main/kotlin/jp/lunaproject/bot/audio/media/PlayListHelper.kt @@ -116,10 +116,10 @@ class PlayListHelper( .setRequiredRange(1, 1) .addOptions( list.map { (i, data) -> - val duration = DateTimeUtil.formatTimestamp(data.track.length.inWholeMilliseconds, true).toNormal + val duration = DateTimeUtil.formatTimestamp(data.durationMillis, true).toNormal - SelectOption.of(data.track.title, data.id.toString()) - .withDescription("${data.track.author.truncate(SelectOption.DESCRIPTION_MAX_LENGTH - (duration.length + 3))} / $duration") + SelectOption.of(data.title, data.id.toString()) + .withDescription("${data.author.truncate(SelectOption.DESCRIPTION_MAX_LENGTH - (duration.length + 3))} / $duration") } ) .build() diff --git a/src/main/kotlin/jp/lunaproject/bot/audio/media/ResponseMediaData.kt b/src/main/kotlin/jp/lunaproject/bot/audio/media/ResponseMediaData.kt index 72d8e24a..160c5a04 100644 --- a/src/main/kotlin/jp/lunaproject/bot/audio/media/ResponseMediaData.kt +++ b/src/main/kotlin/jp/lunaproject/bot/audio/media/ResponseMediaData.kt @@ -114,18 +114,17 @@ sealed class ResponseMediaData { controller.tracks.getOrNull(controller.position.get()) } ?: return null - val track = data.track return PlayingTrack( data.id.toString(), - track.title, - track.uri, + data.title, + data.url, data.artworkUrl, - track.author, - track.length.inWholeMilliseconds.toString(), - track.isSeekable, - track.isStream, - track.source, - track.identifier + data.author, + data.durationMillis.toString(), + data.isSeekable, + data.isStream, + data.source, + data.identifier ) } } @@ -194,15 +193,15 @@ sealed class ResponseMediaData { val (track, member) = data return QueuedTrack( data.id.toString(), - track.title, - track.uri, + data.title, + data.url, data.artworkUrl, - track.author, - track.length.inWholeMilliseconds.toString(), - track.isSeekable, - track.isStream, - track.source, - track.identifier, + data.author, + data.durationMillis.toString(), + data.isSeekable, + data.isStream, + data.source, + data.identifier, Member.of(member) ) } @@ -242,6 +241,13 @@ sealed class ResponseMediaData { companion object { + fun of(track: jp.lunaproject.bot.audio.media.TrackData) = TrackData( + track.title, + track.url, + track.artworkUrl, + track.author + ) + fun of(track: Track) = TrackData( track.title, track.uri!!, diff --git a/src/main/kotlin/jp/lunaproject/bot/audio/media/TrackData.kt b/src/main/kotlin/jp/lunaproject/bot/audio/media/TrackData.kt index b69ff8a6..d5bc0362 100644 --- a/src/main/kotlin/jp/lunaproject/bot/audio/media/TrackData.kt +++ b/src/main/kotlin/jp/lunaproject/bot/audio/media/TrackData.kt @@ -7,18 +7,57 @@ import java.util.* data class TrackData( val track: Track, - val member: Member + val member: Member, + val metadata: Metadata = Metadata() ) { + // yt-dlp 経由の直リンク再生では、Lavalink 側の Track に元ページの情報が残らないことがある。 + // UI 表示はここで持つ補助メタデータを優先して崩れないようにする。 + + data class Metadata( + val title: String? = null, + val url: String? = null, + val artworkUrl: String? = null, + val author: String? = null, + val durationMillis: Long? = null, + val source: String? = null, + val identifier: String? = null + ) + val id: UUID = UUID.randomUUID() + val title + get() = metadata.title ?: track.title + + val url + get() = metadata.url ?: track.uri ?: "" + + val author + get() = metadata.author ?: track.author + + val durationMillis + get() = metadata.durationMillis ?: track.length.inWholeMilliseconds + + val source + get() = metadata.source ?: track.source + + val identifier + get() = metadata.identifier ?: track.identifier + + val isSeekable + get() = track.isSeekable + + val isStream + get() = track.isStream + private var _artworkUrl: String? = null val artworkUrl: String? get() { + // サムネイル取得は遅いことがあるため、最初の解決結果をキャッシュする。 if (_artworkUrl == null) - _artworkUrl = track.artworkUrl ?: "" + _artworkUrl = metadata.artworkUrl ?: track.artworkUrl ?: "" return _artworkUrl?.ifBlank { null } } - fun copy() = TrackData(track, member) + fun copy() = TrackData(track, member, metadata) } diff --git a/src/main/kotlin/jp/lunaproject/bot/audio/media/YtDlpAudioResolver.kt b/src/main/kotlin/jp/lunaproject/bot/audio/media/YtDlpAudioResolver.kt new file mode 100644 index 00000000..fa329dac --- /dev/null +++ b/src/main/kotlin/jp/lunaproject/bot/audio/media/YtDlpAudioResolver.kt @@ -0,0 +1,258 @@ +package jp.lunaproject.bot.audio.media + +import dev.schlaubi.lavakord.rest.loadItem +import dev.schlaubi.lavakord.rest.models.TrackResponse +import jp.lunaproject.bot.Main +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.dv8tion.jda.api.entities.Member +import org.json.JSONArray +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit + +object YtDlpAudioResolver { + + private const val PROCESS_TIMEOUT_SECONDS = 30L + + // YouTube URL のときだけ yt-dlp で実ストリーム URL を引き、再生自体は既存の Lavalink 経路に流す。 + suspend fun resolve(query: String, controller: MediaController, member: Member): LoadResult? { + if (!isEnabled() || MediaType.of(query) != MediaType.YOUTUBE) return null + + return runCatching { + val info = inspect(query) ?: return null + + if (info.entryUrls.isNotEmpty()) { + val tracks = info.entryUrls.mapNotNull { entryUrl -> resolveTrack(entryUrl, controller, member) } + if (tracks.isEmpty()) return null + + LoadResult.Playlist(info.title.ifBlank { tracks.first().title }, tracks) + } else { + val url = info.pageUrl ?: query + resolveTrack(url, controller, member)?.let { LoadResult.Track(it) } + } + }.getOrElse { + Main.LOGGER.warn("yt-dlp resolution failed for {}: {}", query, it.message) + null + } + } + + private fun isEnabled() = Main.botConfig.ytdlp.enabled + + private suspend fun inspect(query: String): QueryInfo? { + val result = runYtDlp( + "--dump-single-json", + "--flat-playlist", + "--skip-download", + query + ) ?: return null + + if (!result.isSuccess) { + Main.LOGGER.warn("yt-dlp inspect failed for {}: {}", query, result.stderr.ifBlank { result.stdout }) + return null + } + + val json = JSONObject(result.stdout) + val entries = json.optJSONArray("entries") + if (entries != null && entries.length() > 0) { + return QueryInfo( + json.optString("title"), + extractPageUrl(json), + buildEntryUrls(entries) + ) + } + + return QueryInfo(json.optString("title"), extractPageUrl(json), emptyList()) + } + + private suspend fun resolveTrack(url: String, controller: MediaController, member: Member): TrackData? { + // 表示用メタデータと再生用 URL は分けて取得する。 + // 前者は元ページのタイトルやサムネイルを残すため、後者は Lavalink に読ませる実 URL のため。 + val metadata = extractMetadata(url) ?: return null + val streamUrl = extractStreamUrl(url) ?: return null + + val res = controller.link.loadItem(streamUrl) + return when (res.loadType) { + TrackResponse.LoadType.TRACK_LOADED -> TrackData( + res.track.toTrack(), + member, + TrackData.Metadata( + title = metadata.title, + url = metadata.pageUrl, + artworkUrl = metadata.artworkUrl, + author = metadata.author, + durationMillis = metadata.durationMillis, + source = MediaType.YOUTUBE.name.lowercase(), + identifier = metadata.identifier + ) + ) + + TrackResponse.LoadType.PLAYLIST_LOADED -> { + val firstTrack = res.tracks.firstOrNull()?.toTrack() ?: return null + TrackData( + firstTrack, + member, + TrackData.Metadata( + title = metadata.title, + url = metadata.pageUrl, + artworkUrl = metadata.artworkUrl, + author = metadata.author, + durationMillis = metadata.durationMillis, + source = MediaType.YOUTUBE.name.lowercase(), + identifier = metadata.identifier + ) + ) + } + + else -> null + } + } + + private suspend fun extractMetadata(url: String): VideoMetadata? { + val result = runYtDlp( + "--dump-single-json", + "--skip-download", + "--no-playlist", + url + ) ?: return null + + if (!result.isSuccess) { + Main.LOGGER.warn("yt-dlp metadata extraction failed for {}: {}", url, result.stderr.ifBlank { result.stdout }) + return null + } + + val json = JSONObject(result.stdout) + return VideoMetadata( + title = json.optString("title").ifBlank { url }, + pageUrl = extractPageUrl(json) ?: url, + artworkUrl = json.optString("thumbnail").ifBlank { null }, + author = json.optString("channel").ifBlank { + json.optString("uploader").ifBlank { "Unknown" } + }, + durationMillis = json.optLong("duration").takeIf { it > 0 }?.times(1000), + identifier = json.optString("id").ifBlank { null } + ) + } + + private suspend fun extractStreamUrl(url: String): String? { + val result = runYtDlp( + "--get-url", + "--format", + "ba/b", + "--no-playlist", + url + ) ?: return null + + if (!result.isSuccess) { + Main.LOGGER.warn("yt-dlp stream url extraction failed for {}: {}", url, result.stderr.ifBlank { result.stdout }) + return null + } + + return result.stdout.lineSequence().map { it.trim() }.firstOrNull { it.isNotBlank() } + } + + private suspend fun runYtDlp(vararg args: String): CommandResult? = withContext(Dispatchers.IO) { + val config = Main.botConfig.ytdlp + val command = mutableListOf(config.command) + command += listOf( + "--ignore-config", + "--encoding", + "utf-8", + "--quiet", + "--no-warnings" + ) + + val cookiesFile = config.cookiesFile?.takeIf { it.isNotBlank() } + val cookiesFromBrowser = config.cookiesFromBrowser?.takeIf { it.isNotBlank() } + // cookie ファイル指定を優先し、未指定ならブラウザ cookie 読み込みにフォールバックする。 + if (cookiesFile != null) { + command += listOf("--cookies", cookiesFile) + } else if (cookiesFromBrowser != null) { + command += listOf("--cookies-from-browser", cookiesFromBrowser) + } + + command += config.extraArgs + command += args + + val process = try { + ProcessBuilder(command).start() + } catch (e: IOException) { + Main.LOGGER.warn("Failed to start yt-dlp command {}: {}", config.command, e.message) + return@withContext null + } + + val completed = process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS) + if (!completed) { + process.destroyForcibly() + Main.LOGGER.warn("yt-dlp process timed out after {} seconds", PROCESS_TIMEOUT_SECONDS) + return@withContext null + } + + CommandResult( + process.exitValue(), + process.inputStream.readBytes().toString(Charsets.UTF_8).trim(), + process.errorStream.readBytes().toString(Charsets.UTF_8).trim() + ) + } + + private fun buildEntryUrls(entries: JSONArray): List { + val urls = mutableListOf() + for (i in 0 until entries.length()) { + val entry = entries.optJSONObject(i) ?: continue + val url = extractPageUrl(entry) ?: continue + urls += url + } + return urls + } + + private fun extractPageUrl(json: JSONObject): String? { + json.optString("webpage_url").takeIf { it.isNotBlank() }?.let { return it } + json.optString("original_url").takeIf { it.isNotBlank() }?.let { return it } + + val rawUrl = json.optString("url").takeIf { it.isNotBlank() } + if (rawUrl != null) { + if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) return rawUrl + + val extractor = json.optString("extractor_key").ifBlank { json.optString("ie_key") } + if (extractor.equals("Youtube", true) || extractor.equals("YoutubeTab", true)) { + return "https://www.youtube.com/watch?v=$rawUrl" + } + } + + val id = json.optString("id").takeIf { it.isNotBlank() } ?: return null + val extractor = json.optString("extractor_key").ifBlank { json.optString("ie_key") } + return if (extractor.equals("Youtube", true) || extractor.equals("YoutubeTab", true)) { + "https://www.youtube.com/watch?v=$id" + } else { + null + } + } + + sealed class LoadResult { + data class Track(val track: TrackData) : LoadResult() + data class Playlist(val name: String, val tracks: List) : LoadResult() + } + + private data class QueryInfo( + val title: String, + val pageUrl: String?, + val entryUrls: List + ) + + private data class VideoMetadata( + val title: String, + val pageUrl: String, + val artworkUrl: String?, + val author: String, + val durationMillis: Long?, + val identifier: String? + ) + + private data class CommandResult( + val exitCode: Int, + val stdout: String, + val stderr: String + ) { + val isSuccess = exitCode == 0 && stdout.isNotBlank() + } +} \ No newline at end of file diff --git a/src/main/kotlin/jp/lunaproject/bot/commands/message/media/Play.kt b/src/main/kotlin/jp/lunaproject/bot/commands/message/media/Play.kt index ef3a33a3..583e0fdc 100644 --- a/src/main/kotlin/jp/lunaproject/bot/commands/message/media/Play.kt +++ b/src/main/kotlin/jp/lunaproject/bot/commands/message/media/Play.kt @@ -10,6 +10,7 @@ import dev.schlaubi.lavakord.rest.models.TrackResponse import jp.lunaproject.bot.Main import jp.lunaproject.bot.audio.media.MediaController import jp.lunaproject.bot.audio.media.TrackData +import jp.lunaproject.bot.audio.media.YtDlpAudioResolver import jp.lunaproject.bot.audio.media.niconico.NicoVideoSearch import jp.lunaproject.bot.components.GlobalComponent import jp.lunaproject.bot.components.commands.media.Play @@ -94,6 +95,36 @@ class Play : MediaMessageCommandImpl() { ) { val component = Play(member.user, channel) + when (val resolved = YtDlpAudioResolver.resolve(query, controller, member)) { + is YtDlpAudioResolver.LoadResult.Track -> { + val track = resolved.track + + msg.edit(component.getLoadedTrackComponent(track).build())?.queue { + GlobalScope.launch(Main.COMMAND_CONTEXT) { + if (controller.channel == null) + controller.channel = audioChannel + controller.messageChannel = channel + controller.play(track) + } + } + return + } + + is YtDlpAudioResolver.LoadResult.Playlist -> { + msg.edit(component.getLoadedPlaylistComponent(resolved.name, resolved.tracks).build())?.queue { + GlobalScope.launch(Main.COMMAND_CONTEXT) { + if (controller.channel == null) + controller.channel = audioChannel + controller.messageChannel = channel + controller.play(resolved.tracks) + } + } + return + } + + null -> Unit + } + val res = controller.link.loadItem(if (query.matches(_urlRegex)) query else "ytsearch: $query") when (res.loadType) { TrackResponse.LoadType.TRACK_LOADED -> { diff --git a/src/main/kotlin/jp/lunaproject/bot/commands/slash/media/Play.kt b/src/main/kotlin/jp/lunaproject/bot/commands/slash/media/Play.kt index d80a7873..b4e08d62 100644 --- a/src/main/kotlin/jp/lunaproject/bot/commands/slash/media/Play.kt +++ b/src/main/kotlin/jp/lunaproject/bot/commands/slash/media/Play.kt @@ -10,6 +10,7 @@ import dev.schlaubi.lavakord.rest.models.TrackResponse import jp.lunaproject.bot.Main import jp.lunaproject.bot.audio.media.MediaController import jp.lunaproject.bot.audio.media.TrackData +import jp.lunaproject.bot.audio.media.YtDlpAudioResolver import jp.lunaproject.bot.audio.media.niconico.NicoVideoSearch import jp.lunaproject.bot.components.GlobalComponent import jp.lunaproject.bot.components.commands.media.Play @@ -95,6 +96,36 @@ class Play : MediaSlashCommandImpl() { ) { val component = Play(member.user, channel) + when (val resolved = YtDlpAudioResolver.resolve(query, controller, member)) { + is YtDlpAudioResolver.LoadResult.Track -> { + val track = resolved.track + + hook.editOriginal(component.getLoadedTrackComponent(track).build()).queue { + GlobalScope.launch(Main.COMMAND_CONTEXT) { + if (controller.channel == null) + controller.channel = audioChannel + controller.messageChannel = channel + controller.play(track) + } + } + return + } + + is YtDlpAudioResolver.LoadResult.Playlist -> { + hook.editOriginal(component.getLoadedPlaylistComponent(resolved.name, resolved.tracks).build()).queue { + GlobalScope.launch(Main.COMMAND_CONTEXT) { + if (controller.channel == null) + controller.channel = audioChannel + controller.messageChannel = channel + controller.play(resolved.tracks) + } + } + return + } + + null -> Unit + } + val res = controller.link.loadItem(if (query.matches(_urlRegex)) query else "ytsearch: $query") when (res.loadType) { TrackResponse.LoadType.TRACK_LOADED -> { diff --git a/src/main/kotlin/jp/lunaproject/bot/components/commands/media/Play.kt b/src/main/kotlin/jp/lunaproject/bot/components/commands/media/Play.kt index 0fcb5d21..8d5ec89b 100644 --- a/src/main/kotlin/jp/lunaproject/bot/components/commands/media/Play.kt +++ b/src/main/kotlin/jp/lunaproject/bot/components/commands/media/Play.kt @@ -81,17 +81,17 @@ class Play(user: User, channel: MessageChannel) : MediaCommandComponentImpl("pla author(embedComponent.author.format(entity, channel, label, command), EmbedUtil.EmbedType.SUCCESS) description = embedComponent.description.format(entity, channel, label, command) - .replace(getPair(value = data.track.title.bold().link(data.track.uri!!))) + .replace(getPair(value = data.title.bold().link(data.url))) addField { name = embedComponent.fields[0].name.format(entity, channel, label, command) - value = data.track.author + value = data.author inline = true } addField { name = embedComponent.fields[1].name.format(entity, channel, label, command) - value = DateTimeUtil.formatTimestamp(data.track.length.inWholeMilliseconds, true).toNormal + value = DateTimeUtil.formatTimestamp(data.durationMillis, true).toNormal inline = true } @@ -126,7 +126,7 @@ class Play(user: User, channel: MessageChannel) : MediaCommandComponentImpl("pla addField { name = embedComponent.fields[1].name.format(entity, channel, label, command) value = DateTimeUtil.formatTimestamp( - tracks.sumOf { it.track.length.inWholeMilliseconds }, + tracks.sumOf { it.durationMillis }, true ).toNormal inline = true diff --git a/src/main/kotlin/jp/lunaproject/bot/components/commands/media/PlayList.kt b/src/main/kotlin/jp/lunaproject/bot/components/commands/media/PlayList.kt index fb0a8c0e..41d46c34 100644 --- a/src/main/kotlin/jp/lunaproject/bot/components/commands/media/PlayList.kt +++ b/src/main/kotlin/jp/lunaproject/bot/components/commands/media/PlayList.kt @@ -33,12 +33,11 @@ class PlayList(user: User, channel: MessageChannel) : MediaCommandComponentImpl( val space = "\u200B ".repeat(if (i < 90) 10 else 12) val index = "${(i + 1).toString().padStart(if (i < 90) 2 else 3)}.".monospace() - val duration = - DateTimeUtil.formatTimestamp(data.track.length.inWholeMilliseconds, true).toNormal + val duration = DateTimeUtil.formatTimestamp(data.durationMillis, true).toNormal """ - $index ${data.track.title.bold().link(data.track.uri!!)} - $space${data.track.author.bold()} / $duration + $index ${data.title.bold().link(data.url)} + $space${data.author.bold()} / $duration """.trimIndent() } }.buildEmbed() @@ -64,12 +63,11 @@ class PlayList(user: User, channel: MessageChannel) : MediaCommandComponentImpl( val space = "\u200B ".repeat(if (i < 90) 10 else 12) val index = "${(i + 1).toString().padStart(if (i < 90) 2 else 3)}.".monospace() - val duration = - DateTimeUtil.formatTimestamp(data.track.length.inWholeMilliseconds, true).toNormal + val duration = DateTimeUtil.formatTimestamp(data.durationMillis, true).toNormal """ - $index ${data.track.title.bold().link(data.track.uri!!)} - $space${data.track.author.bold()} / $duration + $index ${data.title.bold().link(data.url)} + $space${data.author.bold()} / $duration """.trimIndent() } }.buildEmbed() @@ -89,16 +87,16 @@ class PlayList(user: User, channel: MessageChannel) : MediaCommandComponentImpl( val embedComponent = component.embeds[0] title = embedComponent.title.format(entity, channel, label, command) - description = track.title.bold().link(track.uri!!) + description = data.title.bold().link(data.url) addField { name = embedComponent.fields[0].name.format(entity, channel, label, command) - value = track.author + value = data.author } addField { name = embedComponent.fields[1].name.format(entity, channel, label, command) - value = DateTimeUtil.formatTimestamp(track.length.inWholeMilliseconds, true).toNormal + value = DateTimeUtil.formatTimestamp(data.durationMillis, true).toNormal inline = true } @@ -126,16 +124,16 @@ class PlayList(user: User, channel: MessageChannel) : MediaCommandComponentImpl( val embedComponent = component.embeds[0] title = embedComponent.title.format(entity, channel, label, command) - description = track.title.bold().link(track.uri!!) + description = data.title.bold().link(data.url) addField { name = embedComponent.fields[0].name.format(entity, channel, label, command) - value = track.author + value = data.author } addField { name = embedComponent.fields[1].name.format(entity, channel, label, command) - value = DateTimeUtil.formatTimestamp(track.length.inWholeMilliseconds, true).toNormal + value = DateTimeUtil.formatTimestamp(data.durationMillis, true).toNormal inline = true } diff --git a/src/main/kotlin/jp/lunaproject/bot/components/constants/MediaComponent.kt b/src/main/kotlin/jp/lunaproject/bot/components/constants/MediaComponent.kt index 14b5c6e3..5f0c30f3 100644 --- a/src/main/kotlin/jp/lunaproject/bot/components/constants/MediaComponent.kt +++ b/src/main/kotlin/jp/lunaproject/bot/components/constants/MediaComponent.kt @@ -17,8 +17,6 @@ class MediaComponent(guild: Guild, channel: MessageChannel) : ConstantComponentI fun getNextMediaDetail(data: TrackData): MessageCreateBuilder { val component = constantLanguage.getComponent("next_media_detail") - val track = data.track - return MessageCreateBuilder() .setContent(component.content.format(channel)) .setEmbeds( @@ -27,18 +25,18 @@ class MediaComponent(guild: Guild, channel: MessageChannel) : ConstantComponentI author(embedComponent.author.format(channel), Image.PLAY) - title = track.title - url = track.uri!! + title = data.title + url = data.url addField { name = embedComponent.fields[0].name.format(channel) - value = track.author + value = data.author inline = true } addField { name = embedComponent.fields[1].name.format(channel) - value = DateTimeUtil.formatTimestamp(track.length.inWholeMilliseconds, true).toNormal + value = DateTimeUtil.formatTimestamp(data.durationMillis, true).toNormal inline = true } diff --git a/src/main/kotlin/jp/lunaproject/bot/configurations/BotConfig.kt b/src/main/kotlin/jp/lunaproject/bot/configurations/BotConfig.kt index d15a0877..8d02ad83 100644 --- a/src/main/kotlin/jp/lunaproject/bot/configurations/BotConfig.kt +++ b/src/main/kotlin/jp/lunaproject/bot/configurations/BotConfig.kt @@ -29,6 +29,7 @@ data class BotConfig( val database: Database, val redis: Redis, val lavalink: Lavalink, + val ytdlp: YtDlp = YtDlp(), val origins: Origins, val defaults: Defaults, @@ -48,6 +49,8 @@ data class BotConfig( if (discord != other.discord) return false if (database != other.database) return false if (redis != other.redis) return false + if (lavalink != other.lavalink) return false + if (ytdlp != other.ytdlp) return false if (origins != other.origins) return false if (defaults != other.defaults) return false if (niconico != other.niconico) return false @@ -63,6 +66,8 @@ data class BotConfig( result = 31 * result + discord.hashCode() result = 31 * result + database.hashCode() result = 31 * result + redis.hashCode() + result = 31 * result + lavalink.hashCode() + result = 31 * result + ytdlp.hashCode() result = 31 * result + origins.hashCode() result = 31 * result + defaults.hashCode() result = 31 * result + niconico.hashCode() @@ -71,7 +76,7 @@ data class BotConfig( } override fun toString() = - "BotConfig(channel=$channel, activities=${activities.contentToString()}, channels=$channels, discord=$discord, database=$database, redis=$redis, origins=$origins, defaults=$defaults, niconico=$niconico, jwt=$jwt)" + "BotConfig(channel=$channel, activities=${activities.contentToString()}, channels=$channels, discord=$discord, database=$database, redis=$redis, lavalink=$lavalink, ytdlp=$ytdlp, origins=$origins, defaults=$defaults, niconico=$niconico, jwt=$jwt)" enum class BotChannel(val id: Int, val color: Color) { @@ -191,6 +196,18 @@ data class BotConfig( ) } + @Serializable + data class YtDlp( + val enabled: Boolean = true, + val command: String = "yt-dlp", + @SerialName("cookies_file") + val cookiesFile: String? = null, + @SerialName("cookies_from_browser") + val cookiesFromBrowser: String? = null, + @SerialName("extra_args") + val extraArgs: List = emptyList() + ) + @Serializable data class Origins( val page: String, diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index a88f03e1..0230ad7e 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -66,6 +66,24 @@ lavalink: port: 2333 password: 'youshallnotpass' +ytdlp: + enabled: true + # PATH に通っていない場合は yt-dlp.exe の絶対パスを指定 + # command: 'C:/tools/yt-dlp.exe' + command: 'yt-dlp' + # cookies_file を設定すると cookies_from_browser より優先 + # Netscape 形式の cookie ファイル例: + # cookies_file: 'C:/secure/youtube-cookies.txt' + cookies_file: null + # ブラウザの cookie を直接読む例: + # cookies_from_browser: 'chrome' + # cookies_from_browser: 'edge:Default' + # cookies_from_browser: 'firefox:default-release' + cookies_from_browser: null + # 追加オプション例: + # extra_args: ['--extractor-args', 'youtube:player_client=tv_downgraded,web_safari'] + extra_args: [] + origins: page: 'https://yudzuki.lunaproject.jp' resource: 'https://resource.lunaproject.jp' From ecfb82e279d5b234614aa8e606f0cfc207bc3e6b Mon Sep 17 00:00:00 2001 From: kuroneko6423 Date: Mon, 27 Apr 2026 00:24:21 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=E3=82=82=E3=81=A3=E3=81=A8=E3=82=B7?= =?UTF-8?q?=E3=83=B3=E3=83=97=E3=83=AB=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/jp/lunaproject/bot/Main.kt | 8 + .../lunaproject/bot/audio/AudioController.kt | 2 + .../bot/audio/media/YtDlpAudioResolver.kt | 57 ++-- .../bot/audio/media/YtDlpBinaryManager.kt | 245 ++++++++++++++++++ .../bot/configurations/BotConfig.kt | 10 +- .../bot/listeners/functions/AudioListener.kt | 3 + src/main/resources/config.yml | 19 +- 7 files changed, 300 insertions(+), 44 deletions(-) create mode 100644 src/main/kotlin/jp/lunaproject/bot/audio/media/YtDlpBinaryManager.kt diff --git a/src/main/kotlin/jp/lunaproject/bot/Main.kt b/src/main/kotlin/jp/lunaproject/bot/Main.kt index ad8193a7..2c30ae74 100644 --- a/src/main/kotlin/jp/lunaproject/bot/Main.kt +++ b/src/main/kotlin/jp/lunaproject/bot/Main.kt @@ -14,6 +14,7 @@ import jp.lunaproject.bot.api.managers.punishments.BanManager import jp.lunaproject.bot.api.managers.punishments.MuteManager import jp.lunaproject.bot.api.redis.RedisController import jp.lunaproject.bot.api.restful.MainController +import jp.lunaproject.bot.audio.media.YtDlpBinaryManager import jp.lunaproject.bot.configurations.BotConfig import jp.lunaproject.bot.configurations.utils.JSONStorage import jp.lunaproject.bot.listeners.functions.* @@ -204,6 +205,7 @@ object Main { RedisController.connect() loadCaches() + YtDlpBinaryManager.prepareForStartup() loadLanguages() loadBot() @@ -517,6 +519,12 @@ object Main { MuteManager.extends() }, 0, 1, TimeUnit.HOURS) + if (botConfig.ytdlp.autoUpdate) { + scheduledExecutorService.scheduleWithFixedDelay({ + YtDlpBinaryManager.runScheduledUpdate() + }, 0, YtDlpBinaryManager.UPDATE_INTERVAL_HOURS, TimeUnit.HOURS) + } + scheduler.schedule("*/5 * * * *") { RedisController.setStatuses() diff --git a/src/main/kotlin/jp/lunaproject/bot/audio/AudioController.kt b/src/main/kotlin/jp/lunaproject/bot/audio/AudioController.kt index 9e48673e..78f038c1 100644 --- a/src/main/kotlin/jp/lunaproject/bot/audio/AudioController.kt +++ b/src/main/kotlin/jp/lunaproject/bot/audio/AudioController.kt @@ -28,6 +28,7 @@ open class AudioController(val guild: Guild) { open var channel: AudioChannel? get() = _channelId?.let { guild.getChannelById(AudioChannel::class.java, it) } set(channel) { + // setter は実際の VC 接続操作を行う入口で、成功後のキャッシュ更新は VoiceUpdate イベント側で反映する。 if (channel != null) { connect(channel) } else { @@ -142,6 +143,7 @@ open class AudioController(val guild: Guild) { * @author Aoichaan0513 */ fun updateAudioChannel(channel: AudioChannel?) { + // ここでは JDA から通知された実接続状態だけを保持し、connect/disconnect の成否を後追いで同期する。 _channelId = channel?.idLong } diff --git a/src/main/kotlin/jp/lunaproject/bot/audio/media/YtDlpAudioResolver.kt b/src/main/kotlin/jp/lunaproject/bot/audio/media/YtDlpAudioResolver.kt index fa329dac..bb5d7f55 100644 --- a/src/main/kotlin/jp/lunaproject/bot/audio/media/YtDlpAudioResolver.kt +++ b/src/main/kotlin/jp/lunaproject/bot/audio/media/YtDlpAudioResolver.kt @@ -17,7 +17,7 @@ object YtDlpAudioResolver { // YouTube URL のときだけ yt-dlp で実ストリーム URL を引き、再生自体は既存の Lavalink 経路に流す。 suspend fun resolve(query: String, controller: MediaController, member: Member): LoadResult? { - if (!isEnabled() || MediaType.of(query) != MediaType.YOUTUBE) return null + if (MediaType.of(query) != MediaType.YOUTUBE) return null return runCatching { val info = inspect(query) ?: return null @@ -37,9 +37,8 @@ object YtDlpAudioResolver { } } - private fun isEnabled() = Main.botConfig.ytdlp.enabled - private suspend fun inspect(query: String): QueryInfo? { + // ここではまだ本体の音声 URL までは取らず、単曲かプレイリストかを軽く判定する。 val result = runYtDlp( "--dump-single-json", "--flat-playlist", @@ -55,6 +54,7 @@ object YtDlpAudioResolver { val json = JSONObject(result.stdout) val entries = json.optJSONArray("entries") if (entries != null && entries.length() > 0) { + // プレイリストは各要素を一度ページ URL に戻してから、各曲ごとに通常の解決フローへ渡す。 return QueryInfo( json.optString("title"), extractPageUrl(json), @@ -109,6 +109,7 @@ object YtDlpAudioResolver { } private suspend fun extractMetadata(url: String): VideoMetadata? { + // 埋め込みや履歴表示で元の YouTube 情報を維持したいので、先にページ由来の情報を確定させる。 val result = runYtDlp( "--dump-single-json", "--skip-download", @@ -135,6 +136,7 @@ object YtDlpAudioResolver { } private suspend fun extractStreamUrl(url: String): String? { + // 再生側には動画ページ URL ではなく、Lavalink が直接読める実ストリーム URL を渡す。 val result = runYtDlp( "--get-url", "--format", @@ -152,8 +154,13 @@ object YtDlpAudioResolver { } private suspend fun runYtDlp(vararg args: String): CommandResult? = withContext(Dispatchers.IO) { - val config = Main.botConfig.ytdlp - val command = mutableListOf(config.command) + val commandPath = runCatching { YtDlpBinaryManager.resolveCommandPath() }.getOrElse { + Main.LOGGER.warn("Failed to prepare managed yt-dlp binary: {}", it.message) + return@withContext null + } + + val command = mutableListOf(commandPath) + // 実行結果を環境依存にしないため、ローカル設定を無視して UTF-8 の静かな出力に固定する。 command += listOf( "--ignore-config", "--encoding", @@ -162,37 +169,40 @@ object YtDlpAudioResolver { "--no-warnings" ) - val cookiesFile = config.cookiesFile?.takeIf { it.isNotBlank() } - val cookiesFromBrowser = config.cookiesFromBrowser?.takeIf { it.isNotBlank() } - // cookie ファイル指定を優先し、未指定ならブラウザ cookie 読み込みにフォールバックする。 + val cookiesFile = Main.botConfig.ytdlp.cookiesFile?.takeIf { it.isNotBlank() } if (cookiesFile != null) { + // ユーザー設定は cookie txt のみ受け付ける方針なので、ここでだけ注入する。 command += listOf("--cookies", cookiesFile) - } else if (cookiesFromBrowser != null) { - command += listOf("--cookies-from-browser", cookiesFromBrowser) } - command += config.extraArgs command += args + // 更新処理が実行中のバイナリを差し替えないよう、呼び出し中は使用カウントを持つ。 + YtDlpBinaryManager.beginProcess() val process = try { ProcessBuilder(command).start() } catch (e: IOException) { - Main.LOGGER.warn("Failed to start yt-dlp command {}: {}", config.command, e.message) + Main.LOGGER.warn("Failed to start yt-dlp command {}: {}", command.first(), e.message) + YtDlpBinaryManager.endProcess() return@withContext null } - val completed = process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS) - if (!completed) { - process.destroyForcibly() - Main.LOGGER.warn("yt-dlp process timed out after {} seconds", PROCESS_TIMEOUT_SECONDS) - return@withContext null - } + try { + val completed = process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS) + if (!completed) { + process.destroyForcibly() + Main.LOGGER.warn("yt-dlp process timed out after {} seconds", PROCESS_TIMEOUT_SECONDS) + return@withContext null + } - CommandResult( - process.exitValue(), - process.inputStream.readBytes().toString(Charsets.UTF_8).trim(), - process.errorStream.readBytes().toString(Charsets.UTF_8).trim() - ) + CommandResult( + process.exitValue(), + process.inputStream.readBytes().toString(Charsets.UTF_8).trim(), + process.errorStream.readBytes().toString(Charsets.UTF_8).trim() + ) + } finally { + YtDlpBinaryManager.endProcess() + } } private fun buildEntryUrls(entries: JSONArray): List { @@ -213,6 +223,7 @@ object YtDlpAudioResolver { if (rawUrl != null) { if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) return rawUrl + // flat-playlist では動画 ID だけ返ることがあるので、YouTube の watch URL に復元する。 val extractor = json.optString("extractor_key").ifBlank { json.optString("ie_key") } if (extractor.equals("Youtube", true) || extractor.equals("YoutubeTab", true)) { return "https://www.youtube.com/watch?v=$rawUrl" diff --git a/src/main/kotlin/jp/lunaproject/bot/audio/media/YtDlpBinaryManager.kt b/src/main/kotlin/jp/lunaproject/bot/audio/media/YtDlpBinaryManager.kt new file mode 100644 index 00000000..b1066d9c --- /dev/null +++ b/src/main/kotlin/jp/lunaproject/bot/audio/media/YtDlpBinaryManager.kt @@ -0,0 +1,245 @@ +package jp.lunaproject.bot.audio.media + +import jp.lunaproject.bot.Main +import okhttp3.Request +import org.json.JSONObject +import org.apache.commons.lang3.SystemUtils +import java.io.IOException +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption +import java.util.Locale +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +object YtDlpBinaryManager { + + private const val RELEASE_API_URL = "https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest" + private const val VERSION_TIMEOUT_SECONDS = 15L + const val UPDATE_INTERVAL_HOURS = 12L + + // 実行中の yt-dlp がある間はバイナリ差し替えを避けるため、使用中プロセス数を数える。 + private val processCount = AtomicInteger(0) + private val toolsDirectoryPath: Path = Paths.get("./.tools/ytdlp") + + fun prepareForStartup() { + runCatching { + // 自動更新が有効なら起動時点で最新版チェックまで行い、無効でも最低限バイナリだけは揃える。 + if (Main.botConfig.ytdlp.autoUpdate) { + syncManagedBinary(forceDownload = false) + } else { + ensureManagedBinary() + } + }.onFailure { + Main.LOGGER.warn("Failed to prepare managed yt-dlp on startup: {}", it.message) + } + } + + fun resolveCommandPath(): String = ensureManagedBinary().toAbsolutePath().toString() + + fun runScheduledUpdate() { + if (!Main.botConfig.ytdlp.autoUpdate) return + + synchronized(this) { + if (processCount.get() > 0) { + // 再生解決と更新がぶつかると実行ファイルを安全に差し替えられないため、次回周期へ送る。 + Main.LOGGER.info("Skipping yt-dlp auto-update because a yt-dlp process is active.") + return + } + + val binaryPath = runCatching { syncManagedBinary(forceDownload = false) }.getOrElse { + Main.LOGGER.warn("Failed to prepare managed yt-dlp for auto-update: {}", it.message) + return + } + + Main.LOGGER.info("Managed yt-dlp is up to date: {}", binaryPath.fileName) + } + } + + fun beginProcess() { + processCount.incrementAndGet() + } + + fun endProcess() { + processCount.decrementAndGet() + } + + @Synchronized + private fun ensureManagedBinary(): Path { + ensureToolsDirectory() + + val binaryPath = managedBinaryPath() + if (Files.exists(binaryPath)) return binaryPath + + // バイナリがまだ無い初回起動だけは、比較を待たずに強制取得する。 + return syncManagedBinary(forceDownload = true) + } + + @Synchronized + private fun syncManagedBinary(forceDownload: Boolean): Path { + ensureToolsDirectory() + + val binaryPath = managedBinaryPath() + val release = detectLatestReleaseAsset() + if (release != null) { + // 既存バイナリの実バージョンと latest release の tag を比較して、差分がある時だけ更新する。 + val currentVersion = readInstalledVersion(binaryPath) + if (!forceDownload && Files.exists(binaryPath) && currentVersion == release.tagName) { + return binaryPath + } + + downloadManagedBinary(release.downloadUrl, binaryPath) + Main.LOGGER.info( + "Managed yt-dlp synced to {} ({})", + release.tagName, + release.assetName + ) + return binaryPath + } + + if (Files.exists(binaryPath)) return binaryPath + throw IOException("Unable to detect a compatible latest yt-dlp release asset.") + } + + private fun ensureToolsDirectory() { + if (!Files.exists(toolsDirectoryPath)) { + Files.createDirectories(toolsDirectoryPath) + if (SystemUtils.IS_OS_WINDOWS) { + runCatching { + Files.setAttribute(toolsDirectoryPath, "dos:hidden", true, LinkOption.NOFOLLOW_LINKS) + } + } + } + } + + private fun managedBinaryPath(): Path { + val fileName = if (SystemUtils.IS_OS_WINDOWS) "yt-dlp.exe" else "yt-dlp" + return toolsDirectoryPath.resolve(fileName) + } + + private fun downloadManagedBinary(downloadUrl: String, targetPath: Path) { + Main.LOGGER.info("Downloading managed yt-dlp binary from {}", downloadUrl) + + val tempPath = Files.createTempFile(toolsDirectoryPath, "yt-dlp-", ".tmp") + try { + val request = Request.Builder().url(downloadUrl).get().build() + Main.okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) + throw IOException("yt-dlp download failed with status ${response.code}") + + val body = response.body ?: throw IOException("yt-dlp download returned an empty body") + body.byteStream().use { input -> + Files.newOutputStream(tempPath).use { output -> + input.copyTo(output) + } + } + } + + Files.move(tempPath, targetPath, StandardCopyOption.REPLACE_EXISTING) + if (!SystemUtils.IS_OS_WINDOWS) { + targetPath.toFile().setExecutable(true, true) + } + + Main.LOGGER.info("Managed yt-dlp binary is ready: {}", targetPath.toAbsolutePath()) + } finally { + Files.deleteIfExists(tempPath) + } + } + + private fun detectLatestReleaseAsset(): ReleaseAsset? { + val preferredAssets = supportedAssetNames() + if (preferredAssets.isEmpty()) return null + + val request = Request.Builder().url(RELEASE_API_URL).get().build() + Main.okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) + throw IOException("yt-dlp release lookup failed with status ${response.code}") + + val body = response.body?.string()?.takeIf { it.isNotBlank() } + ?: throw IOException("yt-dlp release lookup returned an empty body") + val json = JSONObject(body) + val assets = json.optJSONArray("assets") ?: return null + val tagName = json.optString("tag_name").ifBlank { "latest" } + + // OS と CPU に応じた候補名を優先順で総当たりし、最初に見つかった asset を採用する。 + for (assetName in preferredAssets) { + for (index in 0 until assets.length()) { + val asset = assets.optJSONObject(index) ?: continue + if (!assetName.equals(asset.optString("name"), true)) continue + + val downloadUrl = asset.optString("browser_download_url").ifBlank { null } ?: continue + return ReleaseAsset(tagName, assetName, downloadUrl) + } + } + } + + return null + } + + private fun supportedAssetNames(): List { + val arch = System.getProperty("os.arch")?.lowercase(Locale.ROOT).orEmpty() + + // Linux を主対象にしつつ、他 OS でも GitHub release asset 名へ寄せた候補を返す。 + return when { + SystemUtils.IS_OS_LINUX -> when { + arch.contains("aarch64") || arch.contains("arm64") -> listOf("yt-dlp_linux_aarch64", "yt-dlp") + arch.contains("amd64") || arch.contains("x86_64") -> listOf("yt-dlp_linux", "yt-dlp") + arch.contains("arm") -> listOf("yt-dlp") + else -> listOf("yt-dlp") + } + + SystemUtils.IS_OS_WINDOWS -> when { + arch.contains("aarch64") || arch.contains("arm64") -> listOf("yt-dlp_arm64.exe", "yt-dlp.exe") + arch == "x86" || arch.contains("i386") || arch.contains("i686") -> listOf("yt-dlp_x86.exe", "yt-dlp.exe") + else -> listOf("yt-dlp.exe") + } + + SystemUtils.IS_OS_MAC_OSX -> listOf("yt-dlp_macos") + else -> emptyList() + } + } + + private fun readInstalledVersion(binaryPath: Path): String? { + if (!Files.exists(binaryPath)) return null + + // 更新判定は sidecar ファイルではなく、実際に配置済みの yt-dlp 自身へ問い合わせる。 + beginProcess() + val process = try { + ProcessBuilder(binaryPath.toAbsolutePath().toString(), "--version").start() + } catch (e: IOException) { + Main.LOGGER.warn("Failed to read yt-dlp version from {}: {}", binaryPath, e.message) + endProcess() + return null + } + + try { + val completed = process.waitFor(VERSION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + if (!completed) { + process.destroyForcibly() + Main.LOGGER.warn("yt-dlp version check timed out after {} seconds", VERSION_TIMEOUT_SECONDS) + return null + } + + if (process.exitValue() != 0) { + val stderr = process.errorStream.readBytes().toString(Charsets.UTF_8).trim() + Main.LOGGER.warn("yt-dlp version check failed: {}", stderr) + return null + } + + return process.inputStream.readBytes().toString(Charsets.UTF_8) + .lineSequence() + .map { it.trim() } + .firstOrNull { it.isNotBlank() } + } finally { + endProcess() + } + } + + private data class ReleaseAsset( + val tagName: String, + val assetName: String, + val downloadUrl: String + ) +} \ No newline at end of file diff --git a/src/main/kotlin/jp/lunaproject/bot/configurations/BotConfig.kt b/src/main/kotlin/jp/lunaproject/bot/configurations/BotConfig.kt index 8d02ad83..fdc9b6ab 100644 --- a/src/main/kotlin/jp/lunaproject/bot/configurations/BotConfig.kt +++ b/src/main/kotlin/jp/lunaproject/bot/configurations/BotConfig.kt @@ -198,14 +198,10 @@ data class BotConfig( @Serializable data class YtDlp( - val enabled: Boolean = true, - val command: String = "yt-dlp", + @SerialName("auto_update") + val autoUpdate: Boolean = true, @SerialName("cookies_file") - val cookiesFile: String? = null, - @SerialName("cookies_from_browser") - val cookiesFromBrowser: String? = null, - @SerialName("extra_args") - val extraArgs: List = emptyList() + val cookiesFile: String? = null ) @Serializable diff --git a/src/main/kotlin/jp/lunaproject/bot/listeners/functions/AudioListener.kt b/src/main/kotlin/jp/lunaproject/bot/listeners/functions/AudioListener.kt index 50bae876..91345473 100644 --- a/src/main/kotlin/jp/lunaproject/bot/listeners/functions/AudioListener.kt +++ b/src/main/kotlin/jp/lunaproject/bot/listeners/functions/AudioListener.kt @@ -11,12 +11,15 @@ class AudioListener : ListenerAdapter() { val guild = e.guild val member = e.member + // 監視対象は bot 自身の VC 状態だけで、一般ユーザーの移動通知では内部状態を触らない。 if (member.idLong != guild.selfMember.idLong) return val controller = AudioManager.getAudioController(guild) ?: return when { + // 実際に参加・移動できた時だけ controller 側の接続先キャッシュを更新する。 e.channelJoined != null -> controller.updateAudioChannel(e.channelJoined) + // 切断やキックで VC から外れた時は null に戻して、再生系が古い VC を参照しないようにする。 e.channelLeft != null -> controller.updateAudioChannel(null) } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 0230ad7e..8e91be03 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -67,22 +67,13 @@ lavalink: password: 'youshallnotpass' ytdlp: - enabled: true - # PATH に通っていない場合は yt-dlp.exe の絶対パスを指定 - # command: 'C:/tools/yt-dlp.exe' - command: 'yt-dlp' - # cookies_file を設定すると cookies_from_browser より優先 + # 起動時に OS/CPU を自動判定して互換バイナリを取得 + # true の場合は起動時に最新確認し、起動中も 12 時間ごとに更新 + auto_update: true + # 指定する設定は cookie の txt だけ # Netscape 形式の cookie ファイル例: - # cookies_file: 'C:/secure/youtube-cookies.txt' + # cookies_file: '/opt/lunaproject/youtube-cookies.txt' cookies_file: null - # ブラウザの cookie を直接読む例: - # cookies_from_browser: 'chrome' - # cookies_from_browser: 'edge:Default' - # cookies_from_browser: 'firefox:default-release' - cookies_from_browser: null - # 追加オプション例: - # extra_args: ['--extractor-args', 'youtube:player_client=tv_downgraded,web_safari'] - extra_args: [] origins: page: 'https://yudzuki.lunaproject.jp'