Kanon-Bot 개발 기록: node + Typescript로 Youtube-dl 사용하기

도입

최근 여유 시간에 KanonDL-Bot 라고 하는 텔레그램 메신저 봇을 만들고 있는데, 해당 봇의 메인 기능은 ‘youtube-dl‘로 영상이나 음악을 다운받아 바로 전송해주는 기능이다.

흔히 부르는 Y****** to video/mp3 계열이긴 한데, 만든 사람들이 많아도 하나같이 신뢰할 수 없기에 ts도 다시 공부할겸 직접 만들고 있다.

따라서 금주의 기록에는 Youtube-DL를 node + typescript에서 사용하는 것을 정리하려고 한다.

의존성 설치

기본적으로 Youtube-DL는 각 OS마다 바이너리를 제공하기는 하지만, 여기서는 기 제작된 드라이버인 node-youtube-dl를 사용한다.

npm install youtube-dl

해당 드라이버에서는 postinstall 로 youtubedl의 바이너리를 가져오고 설정하게 된다.

다만 youtube-dl 의 기능 중에서 ffmpeg를 의존하는 기능을 사용하려 할 경우, ffprobe or avconv is not installed 오류를 만나게 된다.

이 때는, 각 OS에 맞춰서 바이너리를 설치하면 된다.

choco install ffmpeg // chocolately (Windows 전용)
apt install ffmpeg // ubuntu (Dockerize 용)

기능 개발

정보 가져오기

해당 드라이버가 getInfo 메서드를 제공하기는 하지만, youtube-dl가 제공하는 모든 기능에 대해 제공하지는 않는다.

따라서 다른 기능도 마찬가지지만 드라이버에서 제공하는 기능을 사용하는 것이 아닌 직접 바이너리를 실행하는 youtube-dl exec 를 사용할 것이다.

먼저 필요한 기능인 ‘정보 가져오기’인 경우, youtube-dl는 자동으로 파일을 다운로드 받으므로 파일을 저장하지 않는 조건으로 가져올 필요가 있다. 해당 명령어는 아래와 같다.

youtube-dl -s -j {url}

여기서 -s는 비 다운로드 옵션, -j는 가져온 정보에 대해 json으로 덤프하는 기능이다.

이를 node-youtube-dl가 제공하는 드라이버로 실행하면 다음과 같다.

import youtubedl = require('youtube-dl');

export function extractInfo(url: string) {
    return new Promise<Media.Info>((resolve, reject) => {
        youtubedl.exec(url, ['-s', '-j'], {}, (err: any, output: string[]) => {
            if (err) {
                reject(err)
                return;
            }

            let message = output.join('\n')
            let info = JSON.parse(message)
            resolve(info)
        })
    });
}

여기에서 Media.Info 는 결과를 정리한 Model이고, 이 쪽에서 참조할 수 있다.

사용하는 쪽에서는 다음과 같이 사용할 수 있다.

import * as YoutubeDLWrapper from '../core/youtubedl'
YoutubeDLWrapper.extractInfo(url)
            .then((info: Media.Info) => {
                 // info - 추출한 정보들
            });

영상 다운로드 하기

기본적인 영상 다운로드 명렁어는 다음과 같다.

youtube-dl {url}

하지만 텔레그램이 지원하는 포맷은 50MB 이하의 mp4 파일 이므로 변환해줄 필요가 있다.

따라서, 아래의 과정을 youtube-dl가 하게 하면 될 것이다.

  1. 확장자가 mp4인 video를 지정한 경로에 다운로드
  2. 확장자가 m4a인 audio를 지정한 경로에 다운로드
  3. video와 audio를 mp4로 인코딩하되, 파일 사이즈는 50MB 미만으로 해서 지정한 경로에 저장

위 조건을 명령어로 하면 다음과 같다.

youtube-dl -f (bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4)[filesize<48M] -o {output_path} {url}

이에 맞춘 파일이 output_path에 최종적으로 저장되며, 아래와 같은 응답으로 오게 된다.

 OPMZTg1k8r0: Downloading webpage
 OPMZTg1k8r0: Downloading video info webpage
[download] Destination: C:\Users\winds\CodeProject\KanonDL-Bot\src\downloads\【公式】Poppin'Party「Dreamers Go!」ライブFull映像【Poppin'Party×SILENT SIREN 「NO GIRL NO CRY」DAY1】.f135.mp4
[download] 100% of 34.10MiB in 00:01
[download] Destination: C:\Users\winds\CodeProject\KanonDL-Bot\src\downloads\【公式】Poppin'Party「Dreamers Go!」ライブFull映像【Poppin'Party×SILENT SIREN 「NO GIRL NO CRY」DAY1】.f140.m4a
[download] 100% of 4.08MiB in 00:00
[ffmpeg] Merging formats into "C:\Users\winds\CodeProject\KanonDL-Bot\src\downloads\【 
公式】Poppin'Party「Dreamers Go!」ライブFull映像【Poppin'Party×SILENT SIREN 「NO GIRL NO CRY」DAY1】.mp4"
Deleting original file C:\Users\winds\CodeProject\KanonDL-Bot\src\downloads\【公式】Poppin'Party「Dreamers Go!」ライブFull映像【Poppin'Party×SILENT SIREN 「NO GIRL NO CRY」DAY1】.f135.mp4 (pass -k to keep)
Deleting original file C:\Users\winds\CodeProject\KanonDL-Bot\src\downloads\【公式】Poppin'Party「Dreamers Go!」ライブFull映像【Poppin'Party×SILENT SIREN 「NO GIRL NO CRY」DAY1】.f140.m4a (pass -k to keep)

응답은 총 1회 호출되므로, 파일이 저장된 곳을 정규식으로 찾아 경로를 반환해주면 될 것이다.

따라서 이를 driver로 표현하면 다음과 같을 것이다.

export function downloadVideo(url: string) {
    return new Promise<string>((resolve, reject) => {
        youtubedl.exec(url, ['-f', '(bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4)[filesize<50M]', '-o', output_path], {}, (err: Error, output: string[]) => {
            if (err) {
                reject("")
                return;
            }

            let message = output.join('\n')
            let match = message.match(/\[ffmpeg] Merging formats into (.+)/)

            if (match != undefined) {
                resolve(match[1].replace(/"/gi, ''))
            }
        });
    });
};

음악 다운로드 하기

음악도 영상과는 크게 다르지 않고, youtube-dl가 아래와 같은 작업을 하게 하면 된다.

  1. 확장자가 webm인 audio를 지정한 경로에 다운로드
  2. webm인 audio를 mp3로 변환

위 조건을 명령어로 하면 다음과 같다.

youtube-dl -f bestaudio -o {output_path} -x --audio-format mp3 {url}

응답은 다음과 같이 오게 된다.

 OPMZTg1k8r0: Downloading webpage
 OPMZTg1k8r0: Downloading video info webpage
[download] Destination: C:\Users\winds\CodeProject\KanonDL-Bot\src\downloads\【公式】Poppin'Party「Dreamers Go!」ライブFull映像【Poppin'Party×SILENT SIREN 「NO GIRL NO CRY」DAY1】.webm
[download] 100% of 3.94MiB in 00:00
[ffmpeg] Destination: C:\Users\winds\CodeProject\KanonDL-Bot\src\downloads\【公式】Poppin'Party「Dreamers Go!」ライブFull映像【Poppin'Party×SILENT SIREN 「NO GIRL NO CRY」DAY1】.mp3
Deleting original file C:\Users\winds\CodeProject\KanonDL-Bot\src\downloads\【公式】Poppin'Party「Dreamers Go!」ライブFull映像【Poppin'Party×SILENT SIREN 「NO GIRL NO CRY」DAY1】.webm (pass -k to keep)

똑같이 저장된 경로를 정규식으로 찾는다면, Driver로는 아래와 같이 표현할 수 있다.

export function downloadAudio(tuple: url) {
    return new Promise<string>((resolve, reject) => {
        youtubedl.exec(url, ['-f', 'bestaudio', '-o', output_path, '-x', '--audio-format', 'mp3'], {}, (err: Error, output: string[]) => {
            if (err) {
                reject('')
                return;
            }

            let message = output.join('\n')
            let match = message.match(/\[ffmpeg\] Destination\: (.+)/)
            if (match != undefined) {
                resolve(match[1])
            }
        });
    });
};

마무리

이로서 기본적인 youtube-dl 기능은 가져왔고, 이에 아이디어를 덧붙이면 괜찮은 봇이 만들어 질 것 같다.

현재까지 개발된 것 중에서 제일 문제라고 하면 멀티 프로세스 문제와 promise의 난해함이라고 할 수 있는데, 그나마 쉽게 적용 가능할 것 같은 RxJs로의 마이그레이션을 다음주쯤에 해볼 것 같다. 아니면 Dockerize를 해볼지도.

JNI에서 RegisterNatives 사용하기

아득히 먼 예전(약 5년 이상)에는 JNI를 사용하기 위해 javah 를 사용하여 헤더 파일을 만들고, 헤더 파일에 선언된 메서드를 사용하는 일이 있었다.

JNIEXPORT void JNICALL Java_com_github_windsekirun_**_bridge_JniBridge_nativeOnStart(JNIEnv *env, jclass type) {
      LAppDelegate::GetInstance()->OnStart();
}

가령 JniBridge 라는 클래스에 nativeOnStart 라는 메서드가 있다면, 위에 선언된 메서드를 실행하는 방식이다.

하나 두개 쯤은 문제가 없지만, 메서드가 많이 있을때는 아무래도 깔끔하지 못하는 단점이 있었다.

이번에 Live2D를 사용한 토이 프로젝트를 진행하면서 다시 JNI를 사용하게 되었는데, 몇주 전 동적으로 메서드를 등록할 수 있다는 방법을 알게 된 터라 한번 사용해보았다.

RegisterNatives

JNI_OnLoad() (네이티브 라이브러리가 로드될 때 초기화 작업하는 메서드) 에서 사용하며, 아래의 파라미터를 받는다.

jclass clazz

위의 JNIBridge처럼 native 메서드가 선언된 클래스이다.

com/github/windsekirun/**/bridge/JniBridge 처럼 선언된다.

JNINativeMethod*

jni.h에 선언된 구조체로, 아래와 같은 형식이다.

typedef struct {
  const char* name;
  const char* signature;
  void*       fnPtr;
} JNINativeMethod;

name는 native 메서드가 선언된 클래스에서의 메서드 이름, signature는 해당 메서드의 JNI 시그니쳐, fnPtr는 jni에서의 메서드로 보면 된다.

가령 아래와 같은 메서드가 있다고 가정한다.

// java
public static native void nativeOnStart();

// c++
static void nativeOnStart(JNIEnv *env, jclass type) {
  LAppDelegate::GetInstance()->OnStart();
}

이 때, JNINativeMethod는 {"nativeOnStart", "()V", (void *) nativeOnStart} 가 된다.

따라서, 두 번째 파라미터에는 이러한 JNINativeMethod의 배열을 삽입하면 된다.

numMethods

두번째의 배열에 대하여 전체 갯수를 적는다.

실제 사용하기

JNIBridge 라는 클래스에 아래 메서드들이 있다.

public static native void nativeOnStart();
public static native void nativeOnPause();
public static native void nativeOnStop();
public static native void nativeOnDestroy();
public static native void nativeOnSurfaceCreated();
public static native void nativeOnSurfaceChanged(int width, int height);
public static native void nativeOnDrawFrame();
public static native void nativeOnTouchesBegan(float pointX, float pointY);
public static native void nativeOnTouchesEnded(float pointX, float pointY);
public static native void nativeOnTouchesMoved(float pointX, float pointY);
public static native void nativeLoadModel(String modelName);

그리고, 이를 등록할 JNINativeMethod* 를 선언한다.

static const char *classPathName = "com/github/windsekirun/**/bridge/JniBridge";

static JNINativeMethod methods[] = {
      {"nativeOnStart",         "()V",                 (void *) nativeOnStart},
      {"nativeOnPause",         "()V",                 (void *) nativeOnPause},
      {"nativeOnStop",           "()V",                 (void *) nativeOnStop},
      {"nativeOnDestroy",       "()V",                 (void *) nativeOnDestroy},
      {"nativeOnSurfaceCreated", "()V",                 (void *) nativeOnSurfaceCreated},
      {"nativeOnSurfaceChanged", "(II)V",               (void *) nativeOnSurfaceChanged},
      {"nativeOnDrawFrame",     "()V",                 (void *) nativeOnDrawFrame},
      {"nativeOnTouchesBegan",   "(FF)V",               (void *) nativeOnTouchesBegan},
      {"nativeOnTouchesEnded",   "(FF)V",               (void *) nativeOnTouchesEnded},
      {"nativeOnTouchesMoved",   "(FF)V",               (void *) nativeOnTouchesMoved},
      {"nativeLoadModel",       "(Ljava/lang/String)V", (void *) nativeLoadModel},
};

마지막으로 RegisterNative 메서드를 실행하는 메서드를 작성한다.

static int registerNativeMethods(JNIEnv *env, const char *className, JNINativeMethod *gMethods,
                                int numMethods) {
  jclass clazz;
  clazz = env->FindClass(className);
  if (clazz == nullptr) {
      return JNI_FALSE;
  }

  if (env->RegisterNatives(clazz, gMethods, numMethods) < 0) {
      return JNI_FALSE;
  }

  return JNI_TRUE;
}

마지막으로 JNI_OnLoad에서 메서드를 실행한다.

registerNativeMethods(env, classPathName, methods, sizeof(methods) / sizeof(methods[0]));

Sonarqube 분석 시도시 Unable to highlight file 문제 해결

프로젝트를 Sonarqube에 연동하여 분석하려고 할 때 다음과 같은 에러가 나오는 일이 종종 있다.

gradlew sonarqube --stacktrace

* Exception is:
org.gradle.api.tasks.TaskExecutionException: Execution failed for task ':app:sonarqube'.
at org.gradle.internal.concurrent.ThreadFactoryImpl$ManagedThreadRunnable.run(ThreadFactoryImpl.java:55)
Caused by: java.lang.IllegalArgumentException: Unable to highlight file src/main/java/com/github/windsekirun/**/data/ImageArticle.kt
... 32 more
Caused by: java.lang.IllegalArgumentException: Start pointer [line=20, lineOffset=0] should be before end pointer [line=20, lineOffset=0]
... 111 more

해당 문제는 특정 파일(ImageArticle.kt) 의 특정 위치에 해독할 수 없는 문자열이 있는 것으로 파악되는데, 주로 \r\n 와 같은 File Encoding 관련 문제일 가능성이 크다.

이 때에는 IDEA 하단 UTF-8 부분을 클릭하여 UTF-16으로 강제 변환한 다음, 다시 UTF-8로 변경하면 해결되는 경우가 많다.

org.gradle.api.tasks.TaskExecutionException: Execution failed for task ':app:sonarqube'.
Caused by: java.lang.IllegalArgumentException: Unable to highlight file src/main/java/com/github/windsekirun/**/data/VideoArticle.kt
... 32 more
Caused by: java.lang.IllegalArgumentException: 713 is not a valid offset for file src/main/java/com/github/windsekirun/**/data/VideoArticle.kt. Max offset is 655
... 104 more

위와 같은 로그가 나올 때도 있는데, 이 때에는 해당 파일의 인코딩이 UTF-16으로 되어있을 때로, 마찬가지로 UTF-8로 다시 변환해주면 된다.

이렇게 수정한 파일들은 실제로 코드가 변경되지 않았지만, line sperators가 변경된 것으로 나오기도 한다.