defmodule Id3vx do
@moduledoc """
Provides the API for interacting with ID3 tags and files that
contain them.
## Examples
### Parse from file
iex> {:ok, tag} = Id3vx.parse_file("test/samples/beamradio32.mp3")
...> tag.version
3
### Encode new tag
Creating tags is most easily done with the utilities in `Id3vx.Tag`.
iex> Id3vx.Tag.create(3)
...> |> Id3vx.Tag.add_text_frame("TIT1", "Title!")
...> |> Id3vx.encode_tag()
<<73, 68, 51, 3, 0, 0, 0, 0, 0, 25, 84, 73, 84, 49, 0, 0, 0, 15, 0, 0, 1, 254, 255, 0, 84, 0, 105, 0, 116, 0, 108, 0, 101, 0, 33>>
### Parse from binary
iex> tag = Id3vx.Tag.create(3)
...> |> Id3vx.Tag.add_text_frame("TIT1", "Title!")
...> tag_binary = Id3vx.encode_tag(tag)
...> {:ok, tag} = Id3vx.parse_binary(tag_binary)
...> tag.version
3
### Add Chapter to an existing ID3 tag
A Chapter often has a URL and image. You can use `Id3vx.Tag.add_attached_picture` for the picture.
iex> tag =
...> "test/samples/beamradio32.mp3"
...> |> Id3vx.parse_file!()
...> |> Id3vx.Tag.add_typical_chapter_and_toc(0, 60_000, 0, 12345,
...> "A Great Title",
...> fn chapter ->
...> Id3vx.Tag.add_custom_url(
...> chapter,
...> "chapter url",
...> "https://underjord.io"
...> )
...> end
...> )
...> tag.version
3
"""
require Logger
alias Id3vx.Tag
alias Id3vx.TagFlags
alias Id3vx.ExtendedHeaderV4
alias Id3vx.ExtendedHeaderV3
alias Id3vx.ExtendedHeaderFlags
alias Id3vx.Error
alias Id3vx.Frame
alias Id3vx.Utils
@parse_states [
:parse_prepend_tag,
:seek_tag,
:parse_extended_header,
:parse_extended_header_flags,
:parse_frames,
:skip_padding,
:parse_footer,
:parse_append_tag,
:done
]
@doc false
def parse_states, do: @parse_states
def tag_to_string(tag) do
"""
version: #{tag.version}
flags: #{inspect(tag.flags)}
frames:
#{frames_to_string(tag.frames)}
"""
end
defp frames_to_string(frames) do
frames
|> Enum.map(&frame_to_string/1)
|> Enum.join("\n")
end
defp frame_to_string(%{frames: frames} = frame) do
"""
#{frame.id} (#{frame.label}):
inspect(#{frame.data})
subframes start:
#{frames_to_string(frames)}
subframes end
"""
end
defp frame_to_string(frame) do
data = Map.delete(frame.data, :__struct__)
"""
#{frame.id} (#{frame.label}): inspect(#{inspect(data)})
"""
end
@doc """
Parse an ID3 tag from the given file path.
It will open the file read-only and only read as many bytes as
necessary.
Returns an `Id3vx.Tag` struct or throws an `Id3vx.Error`.
"""
@spec parse_file!(path :: String.t()) :: Tag.t()
def parse_file!(path) do
try do
case File.open(path, [:read, :binary]) do
{:ok, device} ->
parse_io(device)
{:error, e} ->
throw(%Error{
message: "Could not load file '#{inspect(path)}', error: #{inspect(e)}",
context: {:file_open, e}
})
end
catch
e -> raise e
end
end
@doc """
Parse an ID3 tag from the given file path.
It will open the file read-only and only read as many bytes as
necessary.
Returns `{:ok, Id3vx.Tag}` struct or throws an `{:error, Id3vx.Error}`.
"""
@spec parse_file(path :: String.t()) :: {:ok, Tag.t()} | {:error, %Error{}}
def parse_file(path) do
try do
case File.open(path, [:read, :binary]) do
{:ok, device} ->
{:ok, parse_io(device)}
{:error, e} ->
throw(%Error{
message: "Could not load file '#{inspect(path)}', error: #{inspect(e)}",
context: {:file_open, e}
})
end
catch
e ->
{:error, e}
end
end
@doc """
Parse an ID3 tag from a binary.
Returns an `Id3vx.Tag` struct or throws an `Id3vx.Error`.
"""
def parse_binary!(<<binary::binary>>) do
try do
parse({<<>>, binary})
catch
e -> raise e
end
end
@doc """
Parse an ID3 tag from a binary.
Returns `{:ok, Id3vx.Tag}` struct or throws an `{:error, Id3vx.Error}`.
"""
def parse_binary(binary) do
try do
{:ok, parse({<<>>, binary})}
catch
e -> {:error, e}
end
end
@doc """
Replace an existing ID3 tag in a file with the provided tag producing a new output file.
Returns `:ok` or `{:error, Id3vx.Error}`.
"""
def replace_tag(%Tag{} = tag, infile_path, outfile_path) do
try do
binary = encode_tag(tag)
{:ok, indevice} = File.open(infile_path, [:read, :binary])
{:ok, outdevice} = File.open(outfile_path, [:write, :binary])
tag_header = IO.binread(indevice, 10)
case parse_tag(tag_header) do
{:ok, tag} ->
_skip = IO.binread(indevice, tag.size)
{:unsupported, %{tag_size: tag_size}} ->
_skip = IO.binread(indevice, tag_size)
:not_found ->
:noop
end
IO.binwrite(outdevice, binary)
read_write(indevice, outdevice)
catch
e ->
{:error, e}
end
end
@doc """
Replace an existing ID3 tag in a file with the provided tag producing a new output file.
Returns `:ok` or throws an `Id3vx.Error`.
"""
def replace_tag!(%Tag{} = tag, infile_path, outfile_path) do
binary = encode_tag(tag)
{:ok, indevice} = File.open(infile_path, [:read, :binary])
{:ok, outdevice} = File.open(outfile_path, [:write, :binary])
tag_header = IO.binread(indevice, 10)
case parse_tag(tag_header) do
{:ok, tag} ->
_skip = IO.binread(indevice, tag.size)
{:unsupported, %{tag_size: tag_size}} ->
_skip = IO.binread(indevice, tag_size)
:not_found ->
:noop
end
IO.binwrite(outdevice, binary)
read_write(indevice, outdevice)
end
defp parse_io(device) do
parse(device)
end
# 1 Mb
@chunk_size 1024 * 1024
defp read_write(indevice, outdevice) do
case IO.binread(indevice, @chunk_size) do
:eof ->
:ok
data ->
IO.binwrite(outdevice, data)
read_write(indevice, outdevice)
end
end
@doc """
Find and returns the tag binary without parsing it.
Mostly used in tests.
"""
def get_tag_binary(<<binary::binary>>) do
<<header::binary-size(10), rest::binary>> = binary
{:ok, tag} = parse_tag(header)
tag_size = tag.size
<<body::binary-size(tag_size), _::binary>> = rest
header <> body
end
@doc """
Generate a tag binary from a provided `Id3vx.Tag` struct.
Returns a binary.
"""
@spec encode_tag(tag :: Tag.t()) :: binary()
def encode_tag(%Tag{version: 3} = tag) do
frames = encode_frames(tag)
# unsynchronisation scheme is disabled until we can figure out why it blows up in FFMPEG
# {frames, desynched?, _padded?} = Utils.unsynchronise_if_needed(frames)
desynched? = false
tag_flags =
if is_nil(tag.flags) do
TagFlags.all_false()
else
tag.flags
end
tag = %{tag | flags: %{tag_flags | unsynchronisation: desynched?}}
flags = TagFlags.as_binary(tag.flags, tag)
tag_size =
frames
|> byte_size()
|> Utils.encode_synchsafe_integer()
IO.iodata_to_binary([
"ID3",
<<tag.version>>,
<<tag.revision>>,
flags,
tag_size,
frames
])
end
@doc false
def encode_frames(%Tag{frames: []}) do
throw(%Error{message: "Cannot generate an empty ID3 tag"})
end
@doc false
def encode_frames(%Tag{frames: frames} = tag) do
Enum.reduce(frames, <<>>, fn frame, acc ->
try do
acc <> Frame.encode_frame(frame, tag)
catch
:discard_frame ->
acc
end
end)
end
defp get_bytes(device, bytes) when is_pid(device) do
data = IO.binread(device, bytes)
{data, device}
end
defp get_bytes({used, unused}, bytes) do
<<data::binary-size(bytes), rest::binary>> = unused
{data, {used <> data, rest}}
end
defp parse(source) do
iterate(source, :parse_prepend_tag, nil)
end
defp iterate(source, step, state) do
case parse_step(source, step, state) do
{:done, result} -> result
{next_step, source, state} -> iterate(source, next_step, state)
end
end
defp parse_step(source, :parse_prepend_tag, _) do
{data, source} = get_bytes(source, 10)
case parse_tag(data) do
{:ok, tag} ->
{:parse_frames, source, tag}
{:unsupported, _} ->
throw(%Error{message: "Unsupported tag found", context: :unsupported_tag})
:not_found ->
throw(%Error{message: "Tag not found", context: :parse_prepend_tag})
end
end
defp parse_step(source, :parse_extended_header, tag) do
{:parse_frames, source, tag}
end
defp parse_step(source, :parse_frames, %{version: 4} = tag) do
frames_size =
tag.size
|> subtract_extended_header(tag)
|> subtract_footer(tag)
{data, source} = get_bytes(source, frames_size)
data =
if tag.flags.unsynchronisation do
Utils.decode_unsynchronized(data)
else
data
end
{tag, data} =
if tag.flags.extended_header do
<<ext_header_size::size(32), data::binary>> = data
full_ext_header_size = ext_header_size + 4
<<ext_data::binary-size(full_ext_header_size), data::binary>> = data
ext_header = parse_extended_header_fixed(tag, ext_data)
{%{tag | extended_header: ext_header}, data}
else
{tag, data}
end
frames = Frame.parse_frames(tag, data)
tag = %{tag | frames: frames}
if tag.flags.footer do
{:parse_footer, source, tag}
else
{:skip_padding, source, tag}
end
end
defp parse_step(source, :parse_frames, %{version: 3} = tag) do
frames_size =
tag.size
|> subtract_extended_header(tag)
|> subtract_footer(tag)
{data, _source} = get_bytes(source, frames_size)
data =
if tag.flags.unsynchronisation do
Utils.decode_unsynchronized(data)
else
data
end
frames = Frame.parse_frames(tag, data, [])
tag = %{tag | frames: frames}
{:done, tag}
end
defp parse_step(_source, step, state) do
Logger.warn("Step not implemented #{step}")
{:done, state}
end
defp parse_tag(
<<"ID3", 2::integer, _minor::integer, _flags::size(8), tag_size::binary-size(4)>>
) do
tag_size = Utils.decode_synchsafe_integer(tag_size)
{:unsupported, %{tag_size: tag_size}}
end
defp parse_tag(
<<"ID3", 4::integer, minor::integer, unsynchronisation::size(1),
extended_header::size(1), experimental::size(1), footer::size(1), _unused::size(4),
tag_size::binary-size(4)>>
) do
if true do
tag_size = Utils.decode_synchsafe_integer(tag_size)
{:unsupported, %{tag_size: tag_size}}
else
# Started implementation of version: 4
flags = %TagFlags{
unsynchronisation: unsynchronisation == 1,
extended_header: extended_header == 1,
experimental: experimental == 1,
footer: footer == 1
}
tag_size = Utils.decode_synchsafe_integer(tag_size)
{:ok, %Tag{version: 4, revision: minor, flags: flags, size: tag_size}}
end
end
defp parse_tag(
<<"ID3", 3::integer, minor::integer, flag_bytes::size(8), tag_size::binary-size(4)>>
) do
<<unsynchronisation::size(1), extended_header::size(1), experimental::size(1),
_unused::size(5)>> = <<flag_bytes>>
flags = %TagFlags{
unsynchronisation: unsynchronisation == 1,
extended_header: extended_header == 1,
experimental: experimental == 1
}
tag_size = Utils.decode_synchsafe_integer(tag_size)
{:ok, %Tag{version: 3, revision: minor, flags: flags, size: tag_size}}
end
defp parse_tag(_bin) do
:not_found
end
defp parse_extended_header_fixed(
%{version: 4},
<<size::binary-size(4), _::binary-size(1), _::size(1), is_update::size(1),
crc_data_present::size(1), tag_restrictions::size(1), _unused::size(4)>>
) do
%ExtendedHeaderV4{
size: Utils.decode_synchsafe_integer(size),
flag_bytes: 0x01,
flags: %ExtendedHeaderFlags{
is_update: is_update == 1,
crc_data_present: crc_data_present == 1,
tag_restrictions: tag_restrictions == 1
}
}
end
defp parse_extended_header_fixed(
%{version: 3},
<<size::binary-size(4), crc_data_present::1, _::15, rest::binary>>
) do
{crc_data, padding_size} =
case rest do
<<padding_size::size(32)>> -> {nil, padding_size}
<<padding_size::size(32), crc_data::binary-size(4)>> -> {crc_data, padding_size}
end
%ExtendedHeaderV3{
size: size,
flags: %ExtendedHeaderFlags{
crc_data_present: crc_data_present == 1
},
crc_data: crc_data,
padding_size: padding_size
}
end
defp subtract_extended_header(size, %{
flags: %{extended_header: true},
extended_header: %{size: ex_size}
}) do
size - ex_size
end
defp subtract_extended_header(size, _) do
size
end
defp subtract_footer(size, %{flags: %{footer: true}}) do
size - 10
end
defp subtract_footer(size, _) do
size
end
end