add audio track language selection

AuthorKonata <konata@posteo.jp>
Date
Commiteb1af36fcedfa2bf4a2c00827228e713077d2775
Parent1ddd767
7 files changed, 53 insertions(+), 18 deletions(-)
▾Mserver/src/ffmpeg.ts
@@ -1,6 +1,6 @@
import { AudioCodec, MediaContainer, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
import { createRFC6381String } from "music-server-shared/utils";
import { readStream } from "./utils";
import { analyzeWithMediainfo, readStream } from "./utils";
function secondsToFfmpeg(seconds: number): string {
// Ensure seconds is non-negative
@@ -25,7 +25,7 @@ function secondsToFfmpeg(seconds: number): string {
return `${hoursStr}:${minutesStr}:${secondsStr}.${millisecondsStr}`;
}
export function convertWithFFmpeg(
export async function convertWithFFmpeg(
filePath: string,
audioBitrate: number,
videoBitrate: number,
@@ -34,6 +34,7 @@ export function convertWithFFmpeg(
requestedVideoCodec: VideoCodec,
requestedVideoEncodingSetting: VideoEncodingSetting,
seekTo: number | undefined,
languages: string,
) {
const mimeType = createRFC6381String(container, requestedVideoCodec, requestedAudioCodec);
@@ -187,6 +188,25 @@ export function convertWithFFmpeg(
}
}
const seekParams = seekTo === undefined ? [] : ["-ss", secondsToFfmpeg(seekTo)];
const info = await analyzeWithMediainfo(filePath); //analyze video file with mediainfo to get video duration and other metadata
let selectedAudioTrack = 0; //0 as fallback if no language matches and no default is set
const languageArray = languages.split(",").map((s) => s.toLocaleLowerCase());
if (languageArray.length > 0) {
let index = 0;
for (const track of info.media?.track || []) {
if (track["@type"] === "Audio") {
if (track.Default === "Yes") selectedAudioTrack = index; //use the default track if no language matches
if (track.Language && languageArray.includes(track.Language)) {
selectedAudioTrack = index;
//use the first matching track
break;
}
index++;
}
}
}
const args = ["ffmpeg"]
.concat(seekParams) //put seek first to do a fast keyframe seek
.concat(["-i", filePath])
@@ -196,9 +216,11 @@ export function convertWithFFmpeg(
? ["-c:v", videoCodec, "-b:v", videoBitrate.toString()] //TODO: better bitrate selection
.concat(extraVideoSettings || [])
.concat(videoEncodingSettings)
.concat(["-map", "0:v:0"])
: ["-vn"],
)
.concat(videoEncodingSettings)
.concat(["-map", `0:a:${selectedAudioTrack.toString()}?`])
.concat(["-c:a", audioCodec, "-b:a", audioBitrate.toString(), "-ac", "2"])
.concat(["-f", container, "-"]);
console.log(args.join(" "));
▾Mserver/src/index.ts
@@ -160,7 +160,7 @@ const app = setup
set.headers.Location = `/download/${params["*"]}`;
return "Not a media file, redirecting to normal endpoint";
}
const { cmd, mimeType } = convertWithFFmpeg(
const { cmd, mimeType } = await convertWithFFmpeg(
filePath,
audioBitrate,
videoBitrate || 0,
@@ -169,6 +169,7 @@ const app = setup
query.videoCodec || VideoCodec.none,
query.videoEncodingSetting || VideoEncodingSetting.balanced,
query.seekTo,
query.languages || "",
);
request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
set.headers["content-type"] = mimeType;
@@ -190,6 +191,7 @@ const app = setup
{
query: t.Object({
seekTo: t.Optional(t.Number()),
languages: t.Optional(t.String()),
disableChunkedTranscoding: t.Optional(t.Boolean()),
container: t.Enum(MediaContainer),
videoCodec: t.Optional(t.Enum(VideoCodec)),
▾Mserver/src/utils.ts
@@ -73,13 +73,14 @@ export async function findCover(targetPath: string): Promise<CoverResult> {
return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
}
function decodeString(str?: string) {
if (!str) return undefined;
if (typeof str === "object" && "@dt" in str && "#value" in str && str["@dt"] === "binary.base64") {
return Buffer.from(str["#value"], "base64").toString("utf-8");
}
return str;
// return Buffer.from(str, "utf8").toString("utf-8");
export async function analyzeWithMediainfo(filePath: string) {
const file = Bun.file(filePath);
const fileSize = (await file.stat()).size;
const chunkReader = async (size: number, offset: number) => {
return await file.slice(offset, offset + size).bytes();
};
return await mediaInfo.analyzeData(fileSize, chunkReader);
}
export async function probeFile(filePath: string): Promise<Metadata> {
@@ -95,13 +96,10 @@ export async function probeFile(filePath: string): Promise<Metadata> {
genre: parsed.common.genre,
trackNumber: parsed.common.track.no || undefined,
};
const file = Bun.file(filePath);
const fileSize = (await file.stat()).size;
const chunkReader = async (size: number, offset: number) => {
return await file.slice(offset, offset + size).bytes();
};
const info = await mediaInfo.analyzeData(fileSize, chunkReader);
const info = await analyzeWithMediainfo(filePath);
// for (const track of info.media?.track || []) {
// console.log(track);
// }
const generalTrack = info.media?.track.find((track) => track["@type"] === "General");
const videoTrack = info.media?.track.find((track) => track["@type"] === "Video");
const audioTrack = info.media?.track.find((track) => track["@type"] === "Audio");
▾Mwebclient/src/App.tsx
@@ -62,6 +62,7 @@ const App: Component = () => {
const [options, setOptions] = createStore<AppOptions>({
//default settings
enableTranscoding: true,
languages: "",
disableChunkedTranscoding: false,
maxTranscodes: 4,
maxDownloads: 2,
▾Mwebclient/src/components/OptionsModal.tsx
@@ -184,6 +184,16 @@ export default function OptionsModal(props: OptionsModalProps) {
onChange={(e) => setNewOptions("enableTranscoding", e.target.checked)}
/>
</div>
<div>
<label for="language-input">Language preference:</label>
<input
id="language-input"
type="text"
value={newOptions.languages}
onChange={(e) => setNewOptions("languages", e.target.value)}
disabled={!newOptions.enableTranscoding}
/>
</div>
<div>
<label for="transcoding-buffer-checkbox">Disable chunked transcoding:</label>
<input
▾Mwebclient/src/offline.ts
@@ -385,7 +385,8 @@ export async function getSrc(path: string, options: AppOptions, offline: boolean
`${isVideo ? `&videoEncodingSetting=${options.videoSettings.videoEncodingSetting}` : ""}` +
`&audioCodec=${isVideo ? options.videoSettings.audioCodec : options.audioSettings.audioCodec}` +
`&audioBitrate=${isVideo ? options.videoSettings.audioBitrate : options.audioSettings.audioBitrate}` +
`${seekTo === 0 ? "" : `&seekTo=${seekTo}`}`,
`${seekTo === 0 ? "" : `&seekTo=${seekTo}`}` +
`&languages=${options.languages}`,
isVideo: isVideo,
};
}
▾Mwebclient/src/types.ts
@@ -34,6 +34,7 @@ export enum FileDisplay {
export interface AppOptions {
enableTranscoding: boolean;
languages: string;
disableChunkedTranscoding: boolean;
maxTranscodes: number;
maxDownloads: number;