Skip to content

Commit

Permalink
Add id3v2 export to HLS output. (#3154)
Browse files Browse the repository at this point in the history
This PR implements support for ID3 metadata for HLS output.

From the doc:

## Metadata

HLS outputs supports metadata in two ways:

- Through a `timed_id3` metadata logical stream with the `mpegts`
format.
- Through regular ID3 frames, as requested by the [HLS
specifications](https://datatracker.ietf.org/doc/html/rfc8216#section-3.4)
for `adts`, `mp3`, `ac3` and `eac3` formats.
- There is currently no support for in-stream metadata for the `mp4`
format.

Metadata parameters are passed through the record methods of the
streams' encoders. Here's an example

```liquidsoap
output.file.hls(
  "/path/to/directory",
  [
   ("aac",
      %ffmpeg(format="adts", %audio(codec="aac")).{
        id3_version = 3
       }),
   ("ts-with-meta",
      %ffmpeg(format="mpegts", %audio(codec="aac")).{
        id3_version = 4
     }),
   ("ts",
      %ffmpeg(format="mpegts", %audio(codec="aac")).{
        id3 = false
      }),
   ("mp3",
      %ffmpeg(format="mp3", %audio(codec="libmp3lame")).{
        replay_id3 = false
      })
  ],
  source
)
```

Parameters are:

- `id3`: Set to `false` to deactivate metadata on the streams. Defaults
to `true`.
- `id3_version`: Set the `id3v2` version used to export metadata
- `replay_id3`: By default, the latest metadata is inserted at the
beginning of each segment to make sure new listeners always get the
latest metadata. Set to `false` to disable it.

Metadata for these formats are activated by default. If you are
experiencing any issues with them, you can disable them by setting `id3`
to `false`.



Fixes: #3153
  • Loading branch information
toots authored Jun 24, 2023
1 parent d7e8e8a commit f20d0a8
Show file tree
Hide file tree
Showing 26 changed files with 816 additions and 209 deletions.
43 changes: 43 additions & 0 deletions doc/content/hls_output.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,49 @@ Liquidsoap also provides `output.harbor.hls` which allows to serve HLS streams d
liquidsoap. Their options should be the same as `output.file.hls`, except for harbor-specifc options `port` and `path`. It is
not recommended for listener-facing setup but can be useful to sync up with a caching system such as cloudfront.

## Metadata

HLS outputs supports metadata in two ways:

- Through a `timed_id3` metadata logical stream with the `mpegts` format.
- Through regular ID3 frames, as requested by the [HLS specifications](https://datatracker.ietf.org/doc/html/rfc8216#section-3.4) for `adts`, `mp3`, `ac3` and `eac3` formats.
- There is currently no support for in-stream metadata for the `mp4` format.

Metadata parameters are passed through the record methods of the streams' encoders. Here's an example

```liquidsoap
output.file.hls(
"/path/to/directory",
[
("aac",
%ffmpeg(format="adts", %audio(codec="aac")).{
id3_version = 3
}),
("ts-with-meta",
%ffmpeg(format="mpegts", %audio(codec="aac")).{
id3_version = 4
}),
("ts",
%ffmpeg(format="mpegts", %audio(codec="aac")).{
id3 = false
}),
("mp3",
%ffmpeg(format="mp3", %audio(codec="libmp3lame")).{
replay_id3 = false
})
],
source
)
```

Parameters are:

- `id3`: Set to `false` to deactivate metadata on the streams. Defaults to `true`.
- `id3_version`: Set the `id3v2` version used to export metadata
- `replay_id3`: By default, the latest metadata is inserted at the beginning of each segment to make sure new listeners always get the latest metadata. Set to `false` to disable it.

Metadata for these formats are activated by default. If you are experiencing any issues with them, you can disable them by setting `id3` to `false`.

## Mp4 format

`mp4` container is supported by requires specific parameters. Here's an example that mixes `aac` and `flac` audio, The parameters
Expand Down
38 changes: 38 additions & 0 deletions src/core/builtins/builtins_metadata.ml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
(*****************************************************************************
Liquidsoap, a programmable audio stream generator.
Copyright 2003-2023 Savonet team
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details, fully stated in the COPYING
file at the root of the liquidsoap distribution.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*****************************************************************************)

let _ =
Lang.add_builtin ~base:Liquidsoap_lang.Builtins_string.string "id3v2"
~category:`String
~descr:"Return a string representation of a id3v2 metadata tag"
[
("", Lang.metadata_t, None, None);
( "version",
Lang.int_t,
Some (Lang.int 3),
Some "Tag version. One of: 3 or 4" );
]
Lang.string_t
(fun p ->
let m = Utils.list_of_metadata (Lang.to_metadata (List.assoc "" p)) in
let version = Lang.to_int (List.assoc "version" p) in
Lang.string (Utils.id3v2_of_metadata ~version m))
98 changes: 79 additions & 19 deletions src/core/decoder/ffmpeg_decoder.ml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ exception No_stream

let log = Log.make ["decoder"; "ffmpeg"]

(* Workaround for https://trac.ffmpeg.org/ticket/9540. Should be fixed with
the next FFMpeg release. *)
let parse_timed_id3 content =
if String.length content < 3 then failwith "Invalid content";
if String.sub content 0 3 = "ID3" then
Metadata.Reader.with_string Metadata.ID3.parse content
else (
try
let metadata = Printf.sprintf "ID3\003\000%s" content in
Metadata.Reader.with_string Metadata.ID3.parse metadata
with _ ->
let metadata = Printf.sprintf "ID3\004\000%s" content in
Metadata.Reader.with_string Metadata.ID3.parse metadata)

module Streams = Map.Make (struct
type t = int

Expand Down Expand Up @@ -624,6 +638,23 @@ let get_type ~ctype ~url container =
([], descriptions)
(Av.get_video_streams container)
in
let _, descriptions =
List.fold_left
(fun (n, descriptions) (_, _, params) ->
let field = Frame.Fields.data_n n in
let codec_name =
Avcodec.Unknown.string_of_id (Avcodec.Unknown.get_params_id params)
in
( n + 1,
descriptions
@ [
Printf.sprintf "%s: {codec: %s}"
(Frame.Fields.string_of_field field)
codec_name;
] ))
(0, descriptions)
(Av.get_data_streams container)
in
if audio_streams = [] && video_streams = [] then
failwith "No valid stream found in file.";
let content_type =
Expand Down Expand Up @@ -672,7 +703,7 @@ let get_type ~ctype ~url container =
content_type video_streams
in
log#important "FFmpeg recognizes %s as %s" uri
(String.concat ", " (List.rev descriptions));
(String.concat ", " descriptions);
log#important "Decoded content-type for %s: %s" uri
(Frame.string_of_content_type content_type);
content_type
Expand Down Expand Up @@ -727,41 +758,48 @@ let mk_decoder ~streams ~target_position container =
match v with `Video_packet (s, _) -> s :: cur | _ -> cur)
streams []
in
let data_packet =
Streams.fold
(fun _ v cur -> match v with `Data_packet (s, _) -> s :: cur | _ -> cur)
streams []
in
fun buffer ->
let rec f () =
try
let data =
Av.read_input ~audio_frame ~audio_packet ~video_frame ~video_packet
container
~data_packet container
in
match data with
| `Audio_frame (i, frame) -> (
match Streams.find_opt i streams with
| Some (`Audio_frame (_, decode)) ->
if check_pts (List.hd audio_frame) (Avutil.Frame.pts frame)
then decode ~buffer (`Frame frame)
| Some (`Audio_frame (s, decode)) ->
if check_pts s (Avutil.Frame.pts frame) then
decode ~buffer (`Frame frame)
| _ -> f ())
| `Audio_packet (i, packet) -> (
match Streams.find_opt i streams with
| Some (`Audio_packet (_, decode)) ->
if
check_pts (List.hd audio_packet)
(Avcodec.Packet.get_pts packet)
then decode ~buffer packet
| Some (`Audio_packet (s, decode)) ->
if check_pts s (Avcodec.Packet.get_pts packet) then
decode ~buffer packet
| _ -> f ())
| `Video_frame (i, frame) -> (
match Streams.find_opt i streams with
| Some (`Video_frame (_, decode)) ->
if check_pts (List.hd video_frame) (Avutil.Frame.pts frame)
then decode ~buffer (`Frame frame)
| Some (`Video_frame (s, decode)) ->
if check_pts s (Avutil.Frame.pts frame) then
decode ~buffer (`Frame frame)
| _ -> f ())
| `Video_packet (i, packet) -> (
match Streams.find_opt i streams with
| Some (`Video_packet (_, decode)) ->
if
check_pts (List.hd video_packet)
(Avcodec.Packet.get_pts packet)
then decode ~buffer packet
| Some (`Video_packet (s, decode)) ->
if check_pts s (Avcodec.Packet.get_pts packet) then
decode ~buffer packet
| _ -> f ())
| `Data_packet (i, packet) -> (
match Streams.find_opt i streams with
| Some (`Data_packet (s, decode)) ->
if check_pts s (Avcodec.Packet.get_pts packet) then
decode ~buffer packet
| _ -> f ())
| _ -> ()
with
Expand Down Expand Up @@ -907,6 +945,27 @@ let mk_streams ~ctype ~decode_first_metadata container =
(streams, 0)
(Av.get_video_streams container)
in
let streams, _ =
List.fold_left
(fun (streams, pos) (idx, stream, params) ->
if Avcodec.Unknown.get_params_id params = `Timed_id3 then
( Streams.add idx
(`Data_packet
( stream,
fun ~buffer p ->
let metadata =
try parse_timed_id3 (Avcodec.Packet.content p)
with _ -> []
in
if metadata <> [] then
Generator.add_metadata buffer.Decoder.generator
(Frame.metadata_of_list metadata) ))
streams,
pos + 1 )
else (streams, pos + 1))
(streams, 0)
(Av.get_data_streams container)
in
streams

let create_decoder ~ctype ~metadata fname =
Expand Down Expand Up @@ -971,7 +1030,8 @@ let create_decoder ~ctype ~metadata fname =
| _ -> ());
decoder ~buffer frame
in
`Video_frame (stream, decoder))
`Video_frame (stream, decoder)
| `Data_packet (stream, decoder) -> `Data_packet (stream, decoder))
streams
in
let close () = Av.close container in
Expand Down
1 change: 1 addition & 0 deletions src/core/dune
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@
builtins_files
builtins_harbor
builtins_http
builtins_metadata
builtins_process
builtins_request
builtins_resolvers
Expand Down
65 changes: 62 additions & 3 deletions src/core/encoder/encoder.ml
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,15 @@ let extension = function
| Ffmpeg { Ffmpeg_format.format = Some "mp3" } -> "mp3"
| Ffmpeg { Ffmpeg_format.format = Some "matroska" } -> "mkv"
| Ffmpeg { Ffmpeg_format.format = Some "mpegts" } -> "ts"
| Ffmpeg { Ffmpeg_format.format = Some "ac3" } -> "ac3"
| Ffmpeg { Ffmpeg_format.format = Some "eac3" } -> "eac3"
| Ffmpeg
{
Ffmpeg_format.format = Some "adts";
streams = [(_, `Encode { Ffmpeg_format.codec = Some "aac" })];
} ->
"aac"
| Ffmpeg { Ffmpeg_format.format = Some "adts" } -> "adts"
| Ffmpeg { Ffmpeg_format.format = Some "mp4" } -> "mp4"
| Ffmpeg { Ffmpeg_format.format = Some "wav" } -> "wav"
| _ -> raise Not_found
Expand All @@ -203,8 +212,16 @@ let mime = function
| FdkAacEnc _ -> "audio/aac"
| Ffmpeg { Ffmpeg_format.format = Some "ogg" } -> "application/ogg"
| Ffmpeg { Ffmpeg_format.format = Some "opus" } -> "application/ogg"
| Ffmpeg { Ffmpeg_format.format = Some "libmp3lame" } -> "audio/mpeg"
| Ffmpeg { Ffmpeg_format.format = Some "mp3" } -> "audio/mpeg"
| Ffmpeg { Ffmpeg_format.format = Some "matroska" } -> "video/x-matroska"
| Ffmpeg { Ffmpeg_format.format = Some "ac3" } -> "audio/ac3"
| Ffmpeg { Ffmpeg_format.format = Some "eac3" } -> "audio/eac3"
| Ffmpeg
{
Ffmpeg_format.format = Some "adts";
streams = [(_, `Encode { Ffmpeg_format.codec = Some "aac" })];
} ->
"audio/aac"
| Ffmpeg { Ffmpeg_format.format = Some "mp4" } -> "video/mp4"
| Ffmpeg { Ffmpeg_format.format = Some "wav" } -> "audio/wav"
| _ -> "application/octet-stream"
Expand Down Expand Up @@ -264,15 +281,33 @@ type split_result =
exception Not_enough_data

type hls = {
(* Returns true if id3 is enabled. *)
init : ?id3_enabled:bool -> ?id3_version:int -> unit -> bool;
(* Returns (init_segment, first_bytes) *)
init_encode : Frame.t -> int -> int -> Strings.t option * Strings.t;
split_encode : Frame.t -> int -> int -> split_result;
codec_attrs : unit -> string option;
insert_id3 :
frame_position:int ->
sample_position:int ->
(string * string) list ->
string option;
bitrate : unit -> int option;
(* width x height *)
video_size : unit -> (int * int) option;
}

let dummy_hls encode =
{
init = (fun ?id3_enabled:_ ?id3_version:_ _ -> false);
init_encode = (fun f o l -> (None, encode f o l));
split_encode = (fun f o l -> `Ok (Strings.empty, encode f o l));
codec_attrs = (fun () -> None);
insert_id3 = (fun ~frame_position:_ ~sample_position:_ _ -> None);
bitrate = (fun () -> None);
video_size = (fun () -> None);
}

type encoder = {
insert_metadata : Meta_format.export_metadata -> unit;
(* Encoder are all called from the main
Expand Down Expand Up @@ -310,20 +345,44 @@ let get_factory fmt =
(* Protect all functions with a mutex. *)
let m = Mutex.create () in
let insert_metadata = Tutils.mutexify m insert_metadata in
let { init_encode; split_encode; codec_attrs; bitrate; video_size } =
let {
init;
init_encode;
split_encode;
codec_attrs;
insert_id3;
bitrate;
video_size;
} =
hls
in
let init ?id3_enabled ?id3_version () =
Tutils.mutexify m (fun () -> init ?id3_enabled ?id3_version ()) ()
in
let init_encode frame ofs len =
Tutils.mutexify m (fun () -> init_encode frame ofs len) ()
in
let split_encode frame ofs len =
Tutils.mutexify m (fun () -> split_encode frame ofs len) ()
in
let codec_attrs = Tutils.mutexify m codec_attrs in
let insert_id3 ~frame_position ~sample_position meta =
Tutils.mutexify m
(fun () -> insert_id3 ~frame_position ~sample_position meta)
()
in
let bitrate = Tutils.mutexify m bitrate in
let video_size = Tutils.mutexify m video_size in
let hls =
{ init_encode; split_encode; codec_attrs; bitrate; video_size }
{
init;
init_encode;
split_encode;
codec_attrs;
insert_id3;
bitrate;
video_size;
}
in
let encode frame ofs len =
Tutils.mutexify m (fun () -> encode frame ofs len) ()
Expand Down
9 changes: 9 additions & 0 deletions src/core/encoder/encoder.mli
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,24 @@ type split_result =
exception Not_enough_data

type hls = {
(* Returns true if id3 is enabled. *)
init : ?id3_enabled:bool -> ?id3_version:int -> unit -> bool;
(* Returns (init_segment, first_bytes) *)
init_encode : Frame.t -> int -> int -> Strings.t option * Strings.t;
split_encode : Frame.t -> int -> int -> split_result;
codec_attrs : unit -> string option;
insert_id3 :
frame_position:int ->
sample_position:int ->
(string * string) list ->
string option;
bitrate : unit -> int option;
(* width x height *)
video_size : unit -> (int * int) option;
}

val dummy_hls : (Generator.t -> int -> int -> Strings.t) -> hls

type encoder = {
insert_metadata : Meta_format.export_metadata -> unit;
(* Encoder are all called from the main
Expand Down
Loading

0 comments on commit f20d0a8

Please sign in to comment.