Skip to content

Commit

Permalink
Merge pull request #102 from CXwudi/mka-extract-and-tag
Browse files Browse the repository at this point in the history
✨ Add AnyToMkaAudioExtractor for handling rare audio codecs
  • Loading branch information
CXwudi authored Dec 16, 2024
2 parents 292351f + 339ac69 commit 3eef706
Show file tree
Hide file tree
Showing 43 changed files with 506 additions and 127 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ vvd-downloader-old/logs
vvd-taskproducer/20xx年V家新曲/
vvd-downloader/2021年V家新曲-test-download/
vvd-downloader/manual-test-config.yml
vvd-extractor/2021年V家新曲-extracted-test/
vvd-extractor/20xx年V家新曲-extracted-test/

### My Exe ###
**/aria2c.exe
Expand Down
1 change: 1 addition & 0 deletions docker/env-setup.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# software-properties-common \
locales \
mediainfo \
mkvtoolnix \
&& apt-get clean && rm -rf /var/lib/apt/lists/*

RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen # set UTF-8 to support Chinese and Japanese
Expand Down
48 changes: 1 addition & 47 deletions vvd-extractor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,50 +29,4 @@ All configurations can be found in [`application.yml`](./src/main/resources/appl
Below is a copy of the content of the `application.yml` file for your reference. However, make sure to check for any updates to
the file yourself:

```yaml
io: # all fields are required
# this must be pointing to the output directory of the previous module, the task producer module. In another word, the directory specified in the 'io.output-directory' field of the previous module
# can be an absolute path or a relative path from the application current running directory
input-directory:
# the output directory of this module, can be an absolute path or a relative path from the application current running directory
output-directory:
# the error directory of this module, used for reporting errors for debugging, can be an absolute path or a relative path from the application current running directory
error-directory:

config: # configuration
# all fields are required
environment:
# specify the launch CMD of each dependency as a list of string.
# All commands will be executed using the root directory of the project as the current directory
python-launch-cmd:
ffmpeg-launch-cmd:
mediainfo-launch-cmd:

# all fields are required
batch:
# number of files to process in parallel, default is 0 which represents # of cores
batch-size: 0

# all fields are required
process:
# This two fields control the timeout of each process in this module
# Each process in this module should be run in fairly quick time, even a one-hour video should be run in less than 2 minutes
# if you think you need more time, change the setting here
timeout: 5
unit: minutes

# all fields are required
retry:
retry-on-extraction: 2 # number of times to retry on audio extraction error
retry-on-tagging: 2 # number of times to retry on audio file tagging error

# optional fields
current-time:
# The module will modify the last modified time of the audio file to match the order of input tasks.
# Specifically, a task with a smaller order number in the `-task` JSON file will have an earlier last modified time.
# But the module needs a base timestamp to calculate the last modified time of each audio file
# By default, this field is empty means using the time when this app is launched.
# If you want to start from a specific time, change the setting here
# The format is yyyy-MM-dd HH:mm:ss, with the local time zone in this running device
start-from: ""
```
https://github.com/CXwudi/vocadb-video-downloader-new/blob/292351fcd341b22e8697a01ffdf657a5d9fa979f/vvd-extractor/src/main/resources/application.yml#L1-L62
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import mikufan.cx.inlinelogging.KInlineLogging
import mikufan.cx.vvd.common.exception.RuntimeVocaloidException
import mikufan.cx.vvd.extractor.component.extractor.base.BaseAudioExtractor
import mikufan.cx.vvd.extractor.component.extractor.impl.AacToM4aAudioExtractor
import mikufan.cx.vvd.extractor.component.extractor.impl.AnyToMkaAudioExtractor
import mikufan.cx.vvd.extractor.component.extractor.impl.OpusToOggAudioExtractor
import mikufan.cx.vvd.extractor.component.util.MediaFormatChecker
import mikufan.cx.vvd.extractor.config.IOConfig
import mikufan.cx.vvd.extractor.model.VSongTask
import mikufan.cx.vvd.extractor.util.OrderConstants
Expand Down Expand Up @@ -76,7 +78,10 @@ class ExtractorDeciderCore(
return when (val audioFormat = audioMediaFormatChecker.checkAudioFormat(pvFile)) {
"aac" -> ctx.getBean<AacToM4aAudioExtractor>()
"opus" -> ctx.getBean<OpusToOggAudioExtractor>()
else -> throw RuntimeVocaloidException("Unsupported audio format $audioFormat for song $baseFileName")
else -> {
log.warn { "Unsupported audio format $audioFormat for $baseFileName, fallback to use mka extractor" }
ctx.getBean<AnyToMkaAudioExtractor>()
}
}.also {
log.info { "Decided to use ${it.name} to extract audio from $baseFileName" }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import mikufan.cx.vvd.extractor.component.tagger.base.BaseAudioTagger
import mikufan.cx.vvd.extractor.component.tagger.impl.M4aAudioTagger
import mikufan.cx.vvd.extractor.component.tagger.impl.Mp3AudioTagger
import mikufan.cx.vvd.extractor.component.tagger.impl.OggOpusAudioTagger
import mikufan.cx.vvd.extractor.component.util.MediaFormatChecker
import mikufan.cx.vvd.extractor.model.VSongTask
import mikufan.cx.vvd.extractor.util.AudioMediaFormat
import mikufan.cx.vvd.extractor.util.OrderConstants
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package mikufan.cx.vvd.extractor.component.extractor.impl

import mikufan.cx.vvd.extractor.component.extractor.base.BaseCliAudioExtractor
import mikufan.cx.vvd.extractor.config.EnvironmentConfig
import mikufan.cx.vvd.extractor.config.ProcessConfig
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Component
import java.nio.file.Path
import java.util.concurrent.ThreadPoolExecutor
import kotlin.io.path.exists

/**
* The lossless audio extractor that simply package the audio track into a mka container. (Matroska Audio)
* This container supports almost all audio codecs, including the super rare ones like EAC-3.
*
* It will run this command:
* `ffmpeg -i input.<any format> -vn -acodec copy output.mka`
*
*
* @author CXwudi with love to Miku
* 2024-12-14
*/
@Component
class AnyToMkaAudioExtractor(
processConfig: ProcessConfig,
environmentConfig: EnvironmentConfig,
@Qualifier("extractorThreadPool") threadPool: ThreadPoolExecutor
) : BaseCliAudioExtractor(processConfig, threadPool) {

private val ffmpegLaunchCmd = environmentConfig.ffmpegLaunchCmd

/**
* the name of the audio extractor
*/
override val name: String = "Mka Audio Extractor by FFmpeg"

override fun buildCommand(inputPvFile: Path, baseOutputFileName: String, outputDirectory: Path): List<String> = buildList {
addAll(ffmpegLaunchCmd)
add("-i")
add(inputPvFile.toString())
add("-vn")
add("-acodec")
add("copy")
add("-y")
add(buildFinalAudioFile(outputDirectory, baseOutputFileName).toString())
}

fun buildFinalAudioFile(
outputDirectory: Path,
baseOutputFileName: String
): Path = outputDirectory.resolve("$baseOutputFileName.mka")

override fun findExtractedAudioFile(outputDirectory: Path, baseOutputFileName: String): Path {
val extractedAudioFile = buildFinalAudioFile(outputDirectory, baseOutputFileName)
if (extractedAudioFile.exists()) {
return extractedAudioFile
} else {
throw IllegalStateException("extracted audio file not found: $extractedAudioFile")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package mikufan.cx.vvd.extractor.component.tagger.impl

import mikufan.cx.executil.runCmd
import mikufan.cx.executil.sync
import mikufan.cx.inlinelogging.KInlineLogging
import mikufan.cx.vvd.extractor.component.tagger.base.BaseAudioTagger
import mikufan.cx.vvd.extractor.component.util.MediaFormatChecker
import mikufan.cx.vvd.extractor.config.EnvironmentConfig
import mikufan.cx.vvd.extractor.config.IOConfig
import mikufan.cx.vvd.extractor.config.ProcessConfig
import mikufan.cx.vvd.extractor.model.VSongTask
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Component
import java.nio.file.Files
import java.nio.file.Path
import java.time.format.DateTimeFormatter
import java.util.concurrent.ThreadPoolExecutor
import kotlin.io.path.deleteExisting
import kotlin.io.path.div

/**
*
*
* @author CXwudi with love to Miku
* 2024-12-15
*/
@Component
class MkaAudioTagger(
ioConfig: IOConfig,
environmentConfig: EnvironmentConfig,
private val processConfig: ProcessConfig,
@Qualifier("taggerThreadPool") private val threadPool: ThreadPoolExecutor,
private val mediainfoChecker: MediaFormatChecker,
) : BaseAudioTagger() {
private val mkvpropeditLaunchCmd = environmentConfig.mkvpropeditLaunchCmd
private val inputDirectory = ioConfig.inputDirectory

override val name: String = "Mka Audio Tagger"

override fun tryTag(audioFile: Path, allInfo: VSongTask) {
// 1. build the xml file
val xmlContent = buildXmlContent(allInfo)
val xmlFile = writeXmlFile(xmlContent)
// 2. run the command tagger
runTagCommand(audioFile, xmlFile)
// 3. delete the xml file and build the command for embedding the thumbnail
xmlFile.deleteExisting()
val thumbnailFile = inputDirectory / allInfo.label.thumbnailFileName
// 4. identity the mimetype of the thumbnail file
val imageType = mediainfoChecker.checkImageType(thumbnailFile)
// 4. run the command embedding the thumbnail
runEmbedCommand(audioFile, thumbnailFile, imageType)
}

private fun writeXmlFile(xmlContent: String): Path {
val tempFile = Files.createTempFile("mka-tags-", ".xml")
Files.writeString(tempFile, xmlContent)
log.info { "Created temp xml file $tempFile" }
return tempFile
}

private fun runTagCommand(audioFile: Path, xmlFile: Path) {
val command = buildList {
addAll(mkvpropeditLaunchCmd)
add(audioFile.toString())
add("--tags")
add("all:$xmlFile")
}
log.info { "Running mkvpropedit to tag $audioFile: ${command.joinToString(" ", "`", "`")}" }
runCommand(command)
}

private fun runEmbedCommand(audioFile: Path, thumbnailFile: Path, imageType: String) {
val command = buildList {
addAll(mkvpropeditLaunchCmd)
add(audioFile.toString())
add("--attachment-name")
add("cover.$imageType")
add("--attachment-mime-type")
add("image/$imageType")
add("--attachment-description")
add("cover image")
add("--add-attachment")
add(thumbnailFile.toString())
}
log.info { "Running mkvpropedit to embed $thumbnailFile: ${command.joinToString(" ", "`", "`")}" }
runCommand(command)
}

private fun runCommand(command: List<String>) {
val process = runCmd(command)
process.sync(processConfig.timeout, processConfig.unit, threadPool) {
onStdOutEachLine {
if (it.isNotBlank()) {
log.info { it }
}
}
onStdErrEachLine {
if (it.isNotBlank()) {
log.debug { it }
}
}
}
process.exitValue().let {
if (it != 0) {
throw IllegalStateException("Command failed with exit code $it")
}
}
}


private fun buildXmlContent(allInfo: VSongTask): String {
val songInfo = requireNotNull(allInfo.parameters.songForApiContract) { "songForApiContract must not be null" }
val label = allInfo.label
val songName = songInfo.defaultName
log.info { "Building xml content for $songName" }
val artistsString = requireNotNull(songInfo.artistString) { "artist string is null" }
val producers = artistsString.split("feat.")[0].trim()
val pvId = label.vocaDbPvId
val dateString = songInfo.publishDate.format(DateTimeFormatter.ISO_DATE)

val vocaDbId = songInfo.id
val downloaderName = label.downloaderName
val extractorName = requireNotNull(allInfo.parameters.chosenAudioExtractor) { "null audio extractor for $songName? " }
.map { it.name } // get the name of the audio extractor
.orElse("No Extractor") // if the optional is null, it means the audio itself is there, not from extraction

// Find the PV info
val pvInfo = songInfo.pvs.find { it.id == pvId }
val pvUrl = pvInfo?.url ?: "No PV URL"

// Build album info if present
val albumInfo = if (songInfo.albums.isNotEmpty()) {
val albumNames = songInfo.albums.joinToString(", ") { it.name }
"Albums [$albumNames]"
} else {
null
}

return """
<?xml version="1.0" encoding="UTF-8"?>
<Tags>
<Tag>
<Targets>
<TargetTypeValue>50</TargetTypeValue>
</Targets>
<Simple>
<Name>GENRE</Name>
<String>VOCALOID</String>
</Simple>
${if (albumInfo != null) """
<Simple>
<Name>INCLUDED BY</Name>
<String>$albumInfo</String>
</Simple>
""" else ""}
</Tag>
<Tag>
<Targets>
<TargetTypeValue>30</TargetTypeValue>
</Targets>
<Simple>
<Name>TITLE</Name>
<String>$songName</String>
</Simple>
<Simple>
<Name>ARTIST</Name>
<String>$artistsString</String>
</Simple>
<Simple>
<Name>DATE_RECORDED</Name>
<String>$dateString</String>
</Simple>
<Simple>
<Name>COMMENT</Name>
<String>All rights belong to $producers</String>
</Simple>
<Simple>
<Name>DOWNLOADED BY</Name>
<String>$downloaderName</String>
</Simple>
<Simple>
<Name>PV URL</Name>
<String>$pvUrl</String>
</Simple>
<Simple>
<Name>EXTRACTED BY</Name>
<String>$extractorName</String>
</Simple>
<Simple>
<Name>TAGS EDITED BY</Name>
<String>$name</String>
</Simple>
<Simple>
<Name>TAGS PROVIDED BY</Name>
<String>VocaDB (https://vocadb.net/S/$vocaDbId)</String>
</Simple>
<Simple>
<Name>MADE BY</Name>
<String>CXwudi's vocadb-video-downloader-new (https://github.com/CXwudi/vocadb-video-downloader-new)</String>
</Simple>
</Tag>
</Tags>
""".trimIndent()
}
}


private val log = KInlineLogging.logger()
Loading

0 comments on commit 3eef706

Please sign in to comment.