Skip to content

Commit

Permalink
Add support for the APIC frame type
Browse files Browse the repository at this point in the history
https://id3.org/id3v2.3.0#Attached_picture

There are multiple ways to attach a picture to an MP3 file:

1. Embed a base64 encoded image.
2. Embed a base64 encoded image in a "data" URL (https://developer.mozilla.org/en-US/docs/Web/URI/Schemes/data).
3. Add a URL pointing to a file on the local host.
4. Add a URL pointing to a remote location.

The first three store the image data in the MP3 file, while the last one
stores the URL itself.

The visual representation (for the purpose of the `get` command) of an
APIC frame is a data URL that contains a base64 encoded image.
  • Loading branch information
malor committed Dec 29, 2024
1 parent bc33450 commit c6a49a4
Show file tree
Hide file tree
Showing 6 changed files with 562 additions and 1 deletion.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ TENC = Шо по коду?
TCON = Podcast
TLAN = ukr
WORS = https://xn--d1allabd6a7a.xn--j1amh
APIC = https://github.com/shopokodu/community/blob/main/assets/logo-square-day.svg
00:00:00 Початок
00:02:00 Помилка на мільярд доларів
Expand Down Expand Up @@ -82,6 +83,7 @@ timestamp = "00:00:00"
Most commonly used ID3 frames are supported. The complete list of
supported/unsupported frames could be found below.

- [X] APIC
- [x] CHAP
- [x] CTOC
- [x] TBPM
Expand Down Expand Up @@ -230,7 +232,6 @@ supported/unsupported frames could be found below.
- [ ] RVA
- [ ] RVRB
- [ ] REV
- [ ] APIC
- [ ] PIC
- [ ] PCNT
- [ ] CNT
Expand Down
4 changes: 4 additions & 0 deletions src/id3manager/formats/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ def write(self, fileobj: t.IO, frames: t.List[id3.Frame]) -> None:
value = frame.text[0]
elif isinstance(frame, id3.UrlFrame):
value = frame.url
elif isinstance(frame, id3.APIC):
value = utils.unparse_apic(frame)
else:
raise ValueError(f"{frame.FrameID}: unsupported frame")
print(frame.FrameID, "=", value, file=fileobj)
Expand Down Expand Up @@ -59,6 +61,8 @@ def parse_text_frames(frames_txt: str) -> t.List[id3.Frame]:
frame = frame_cls(text=[value])
elif issubclass(frame_cls, id3.UrlFrame):
frame = frame_cls(url=value)
elif issubclass(frame_cls, id3.APIC):
frame = utils.parse_apic(value)
else:
raise ValueError(f"{frame}: unsupported frame")
frames.append(frame)
Expand Down
17 changes: 17 additions & 0 deletions src/id3manager/formats/toml.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ def read(self, fileobj: t.IO) -> t.List[id3.Frame]:
frame = frame_cls(text=[frame["text"]])
elif issubclass(frame_cls, id3.UrlFrame):
frame = frame_cls(url=frame["url"])
elif issubclass(frame_cls, id3.APIC):
mime = frame.get("mime")
description = frame.get("description")
type = getattr(id3.PictureType, frame.get("type", "COVER_FRONT"))

frame = utils.parse_apic(frame["data"], mime, type)
if description:
frame.desc = description
elif issubclass(frame_cls, id3.CHAP):
timestamp = utils.parse_timestamp_to_ms(frame["timestamp"])
chapter_title = frame["text"]
Expand All @@ -49,6 +57,15 @@ def write(self, fileobj: t.IO, frames: t.List[id3.Frame]) -> None:
output[frame.FrameID].append({"text": str(frame.text[0])})
elif isinstance(frame, id3.UrlFrame):
output[frame.FrameID].append({"url": frame.url})
elif isinstance(frame, id3.APIC):
data = {
"mime": frame.mime,
"data": utils.unparse_apic(frame).rsplit(",", maxsplit=1)[-1],
}
if frame.desc:
data["description"] = frame.desc

output[frame.FrameID].append(data)
elif isinstance(frame, id3.CHAP):
output[frame.FrameID].append(
{
Expand Down
73 changes: 73 additions & 0 deletions src/id3manager/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import base64
import mimetypes
import typing as t
import urllib.parse as urlparse

import mutagen.id3 as id3


def parse_timestamp_to_ms(timestamp: str, sep: str = ":") -> int:
parts = [float(part) for part in timestamp.split(sep)]
parts = [0] * (3 - len(parts)) + parts
Expand All @@ -23,3 +31,68 @@ def ms_to_human_time(ms: int) -> str:

def sec_to_ms(sec: float):
return int(sec * 1000)


def parse_apic(
value: str,
mime: t.Optional[str] = None,
type: id3.PictureType = id3.PictureType.COVER_FRONT,
) -> id3.APIC:
result = urlparse.urlparse(value)
match result.scheme:
case "data":
# Data URL (https://developer.mozilla.org/en-US/docs/Web/URI/Schemes/data).
# Image data is embedded in the URL as a base64 value and stored alongside
# the MIME type.
#
# E.g. "data:image/png;base64,SGVsbG8sIFdvcmxkIQ==".
try:
mime, encoded_value = result.path.split(";", maxsplit=1)
encoding, data = encoded_value.split(",", maxsplit=1)
except ValueError:
raise ValueError(f"Invalid data URL value: `{value}`")

if encoding != "base64":
raise ValueError(f"Unsupported encoding: `{encoding}`")

return id3.APIC(mime=mime, data=base64.b64decode(data), type=type)
case "file":
# URL specifying the location of an image file on the local host. The data
# is loaded to be stored in the APIC frame. The MIME type is deducted from
# the file extension (if not provided explicitly).
#
# E.g. "file:///path/to/image.jpeg"
if not mime:
mime, _ = mimetypes.guess_type(value)

return id3.APIC(mime=mime, data=open(result.path, "rb").read(), type=type)
case "http" | "https":
# URL specifying the remote location. The URL is *not* resolved and is stored
# as is in the APIC frame. The special MIME type "-->" is used per specification
# (https://id3.org/id3v2.3.0).
#
# E.g. "https://www.site.com/my/image.png".
return id3.APIC(mime="-->", data=value.encode(), type=type)
case _:
# If not a URL, just try to interpret the value as a base64 encoded image data.
try:
data = base64.b64decode(value)
except Exception:
raise ValueError(f"Invalid base64 value: `{value}`")

if not mime:
raise ValueError("MIME type not specified")

return id3.APIC(mime=mime, data=base64.b64decode(value), type=type)


def unparse_apic(frame: id3.APIC) -> str:
if frame.mime == "-->":
# Remote URL. Represented by its location.
value = frame.data.decode()
else:
# Embedded data. Represented as a "data" URL.
data = base64.b64encode(frame.data).decode()
value = f"data:{frame.mime};base64,{data}"

return value
Loading

0 comments on commit c6a49a4

Please sign in to comment.