diff --git a/CMakeLists.txt b/CMakeLists.txt index d21ed3d..a86bf39 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,6 +13,14 @@ add_subdirectory( include_directories( taglib/taglib taglib/taglib/toolkit + taglib/taglib/mpeg + taglib/taglib/mpeg/id3v1 + taglib/taglib/mpeg/id3v2 + taglib/taglib/mpeg/id3v2/frames + taglib/taglib/ogg + taglib/taglib/ogg/flac + taglib/taglib/flac + taglib/taglib/mp4 ) add_executable(taglib taglib.cpp) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2506dff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM ghcr.io/webassembly/wasi-sdk:sha-5e4756e + +RUN apt-get update +RUN apt-get install -y --no-install-recommends binaryen +RUN rm -rf /var/lib/apt/lists/* + +COPY . /taglib + +WORKDIR /taglib \ No newline at end of file diff --git a/README.md b/README.md index 670e807..a38b0cc 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,12 @@ func main() { ## Manually Building and Using the WASM Binary -The binary is already included in the package. However if you want to manually build and override it, you can with WASI SDK and Go build flags +A docker file is provided to build the WASM binary. +[WASI SDK](https://github.com/WebAssembly/wasi-sdk) (includes Autotools, CMake, and Ninja) and +[Binaryen](https://github.com/WebAssembly/binaryen) are installed during the docker container build. -1. Install [WASI SDK](https://github.com/WebAssembly/wasi-sdk) globally. The default installation path is `/opt/wasi-sdk/` -2. Install [Binaryen](https://github.com/WebAssembly/binaryen) globally. -3. Clone this repository and Git submodules +1. Install Docker +2. Clone this repository and Git submodules ```console $ git clone "https://github.com/sentriz/go-taglib.git" --recursive @@ -96,14 +97,14 @@ The binary is already included in the package. However if you want to manually b > [!NOTE] > Make sure to use the `--recursive` flag, without it there will be no TagLib submodule to build with -4. Generate the WASM binary: +3. Generate the WASM binary: ```console - $ go generate ./... + $ docker compose run taglib ./build-wasm.sh $ # taglib.wasm created ``` -5. Use the new binary in your project +4. Use the new binary in your project ```console $ CGO_ENABLED=0 go build -ldflags="-X 'go.senan.xyz/taglib.binaryPath=/path/to/taglib.wasm'" ./your/project/... diff --git a/build-wasm.sh b/build-wasm.sh new file mode 100644 index 0000000..21490d5 --- /dev/null +++ b/build-wasm.sh @@ -0,0 +1,5 @@ +#!/bin/bash +cmake -DWASI_SDK_PREFIX=/opt/wasi-sdk -DCMAKE_TOOLCHAIN_FILE=/opt/wasi-sdk/share/cmake/wasi-sdk.cmake -B build . +cmake --build build --target taglib +mv build/taglib.wasm . +wasm-opt --strip -c -O3 taglib.wasm -o taglib.wasm diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..827d68b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + taglib: + build: + context: . + dockerfile: Dockerfile + container_name: taglib + volumes: + - .:/taglib + working_dir: /taglib diff --git a/taglib.cpp b/taglib.cpp index 78d7e26..f3cc027 100644 --- a/taglib.cpp +++ b/taglib.cpp @@ -4,6 +4,13 @@ #include "fileref.h" #include "tpropertymap.h" +#include "mpeg/mpegfile.h" +#include "mpeg/id3v1/id3v1tag.h" +#include "mpeg/id3v2/id3v2tag.h" +#include "mpeg/id3v2/frames/textidentificationframe.h" +#include "mpeg/id3v2/frames/commentsframe.h" +#include "mpeg/id3v2/frames/popularimeterframe.h" +#include "mpeg/id3v2/frames/unsynchronizedlyricsframe.h" char *to_char_array(const TagLib::String &s) { const std::string str = s.to8Bit(true); @@ -101,3 +108,257 @@ taglib_file_audioproperties(const char *filename) { return arr; } + +__attribute__((export_name("taglib_file_id3v2_frames"))) char ** +taglib_file_id3v2_frames(const char *filename) { + // First check if this is an MP3 file with ID3v2 tags + TagLib::FileRef fileRef(filename); + if (fileRef.isNull()) + return nullptr; + + // Try to cast to MPEG::File + TagLib::MPEG::File *mpegFile = dynamic_cast(fileRef.file()); + if (!mpegFile || !mpegFile->hasID3v2Tag()) { + // Return empty array instead of nullptr when there are no ID3v2 tags + char **emptyFrames = static_cast(malloc(sizeof(char *))); + if (!emptyFrames) + return nullptr; + emptyFrames[0] = nullptr; + return emptyFrames; + } + + TagLib::ID3v2::Tag *id3v2Tag = mpegFile->ID3v2Tag(); + const TagLib::ID3v2::FrameListMap &frameListMap = id3v2Tag->frameListMap(); + + // Count total number of frames + size_t frameCount = 0; + for (TagLib::ID3v2::FrameListMap::ConstIterator it = frameListMap.begin(); it != frameListMap.end(); ++it) { + frameCount += it->second.size(); + } + + if (frameCount == 0) { + // Return empty array if there are no frames + char **emptyFrames = static_cast(malloc(sizeof(char *))); + if (!emptyFrames) + return nullptr; + emptyFrames[0] = nullptr; + return emptyFrames; + } + + // Allocate result array + char **frames = static_cast(malloc(sizeof(char *) * (frameCount + 1))); + if (!frames) + return nullptr; + + size_t i = 0; + + // Process each frame + for (TagLib::ID3v2::FrameListMap::ConstIterator it = frameListMap.begin(); it != frameListMap.end(); ++it) { + TagLib::String frameID = TagLib::String(it->first); + + for (TagLib::ID3v2::FrameList::ConstIterator frameIt = it->second.begin(); frameIt != it->second.end(); ++frameIt) { + TagLib::String key = frameID; + TagLib::String value; + + // Handle special frame types + if (frameID == "TXXX") { + // User text identification frame + auto userFrame = dynamic_cast(*frameIt); + if (userFrame) { + key = frameID + ":" + userFrame->description(); + if (!userFrame->fieldList().isEmpty()) { + value = userFrame->fieldList().back(); + } + } + } + else if (frameID == "COMM") { + // Comments frame + auto commFrame = dynamic_cast(*frameIt); + if (commFrame) { + key = frameID + ":" + commFrame->description(); + value = commFrame->text(); + } + } + else if (frameID == "POPM") { + // Popularimeter frame (used for WMP ratings) + auto popmFrame = dynamic_cast(*frameIt); + if (popmFrame) { + key = frameID + ":" + popmFrame->email(); + value = TagLib::String::number(popmFrame->rating()); + } + } + else { + // Standard frame + value = (*frameIt)->toString(); + } + + // Create the output string + TagLib::String row = key + "\t" + value; + frames[i++] = to_char_array(row); + } + } + + frames[i] = nullptr; + return frames; +} + +__attribute__((export_name("taglib_file_id3v1_tags"))) char ** +taglib_file_id3v1_tags(const char *filename) { + // First check if this is an MP3 file with ID3v1 tags + TagLib::FileRef fileRef(filename); + if (fileRef.isNull()) + return nullptr; + + // Try to cast to MPEG::File + TagLib::MPEG::File *mpegFile = dynamic_cast(fileRef.file()); + if (!mpegFile || !mpegFile->hasID3v1Tag()) { + // Return empty array instead of nullptr when there are no ID3v1 tags + char **emptyTags = static_cast(malloc(sizeof(char *))); + if (!emptyTags) + return nullptr; + emptyTags[0] = nullptr; + return emptyTags; + } + + TagLib::ID3v1::Tag *id3v1Tag = mpegFile->ID3v1Tag(); + + // ID3v1 has a fixed set of fields + const int fieldCount = 7; // title, artist, album, year, comment, track, genre + char **tags = static_cast(malloc(sizeof(char *) * (fieldCount + 1))); + if (!tags) + return nullptr; + + int i = 0; + + // Add each standard ID3v1 field + if (!id3v1Tag->title().isEmpty()) + tags[i++] = to_char_array(TagLib::String("TITLE\t") + id3v1Tag->title()); + + if (!id3v1Tag->artist().isEmpty()) + tags[i++] = to_char_array(TagLib::String("ARTIST\t") + id3v1Tag->artist()); + + if (!id3v1Tag->album().isEmpty()) + tags[i++] = to_char_array(TagLib::String("ALBUM\t") + id3v1Tag->album()); + + // Year is an unsigned int in ID3v1, convert to string + if (id3v1Tag->year() > 0) + tags[i++] = to_char_array(TagLib::String("YEAR\t") + TagLib::String::number(id3v1Tag->year())); + + if (!id3v1Tag->comment().isEmpty()) + tags[i++] = to_char_array(TagLib::String("COMMENT\t") + id3v1Tag->comment()); + + if (id3v1Tag->track() > 0) + tags[i++] = to_char_array(TagLib::String("TRACK\t") + TagLib::String::number(id3v1Tag->track())); + + // Genre is an int in ID3v1, need to get the string representation + if (id3v1Tag->genreNumber() != 255) { // 255 is used for "unknown genre" + if (!id3v1Tag->genre().isEmpty()) + tags[i++] = to_char_array(TagLib::String("GENRE\t") + id3v1Tag->genre()); + } + + tags[i] = nullptr; + return tags; +} + +__attribute__((export_name("taglib_file_write_id3v2_frames"))) bool +taglib_file_write_id3v2_frames(const char *filename, const char **frames, uint8_t opts) { + if (!filename || !frames) + return false; + + // First check if this is an MP3 file with ID3v2 tags + TagLib::MPEG::File file(filename); + if (!file.isValid()) + return false; + + // Create a new ID3v2 tag if one doesn't exist + if (!file.hasID3v2Tag()) { + file.ID3v2Tag(true); + } + + TagLib::ID3v2::Tag *id3v2Tag = file.ID3v2Tag(); + + // If clear option is set, collect all frame IDs we want to keep + bool clearFrames = (opts & CLEAR); + + // First collect all the frame IDs we're going to set + std::vector frameIDsToKeep; + if (clearFrames) { + for (int i = 0; frames[i] != nullptr; i++) { + TagLib::String row(frames[i], TagLib::String::UTF8); + int ti = row.find("\t"); + if (ti != -1) { + TagLib::String key = row.substr(0, ti); + // Store the base frame ID (without description for TXXX, COMM, etc.) + if (key.find(":") != -1) { + key = key.substr(0, key.find(":")); + } + frameIDsToKeep.push_back(key.data(TagLib::String::Latin1)); + } + } + + // Now remove all frames except those we're going to set + const TagLib::ID3v2::FrameListMap &frameListMap = id3v2Tag->frameListMap(); + for (TagLib::ID3v2::FrameListMap::ConstIterator it = frameListMap.begin(); + it != frameListMap.end(); ++it) { + bool keepFrame = false; + for (size_t i = 0; i < frameIDsToKeep.size(); ++i) { + if (it->first == frameIDsToKeep[i]) { + keepFrame = true; + break; + } + } + if (!keepFrame) { + id3v2Tag->removeFrames(it->first); + } + } + } + + // Now add the new frames + for (int i = 0; frames[i] != nullptr; i++) { + TagLib::String row(frames[i], TagLib::String::UTF8); + int ti = row.find("\t"); + if (ti != -1) { + TagLib::String key = row.substr(0, ti); + TagLib::String value = row.substr(ti + 1); + + // Remove existing frames with this ID + id3v2Tag->removeFrames(key.toCString(true)); + + // Add new frame if value is not empty + if (!value.isEmpty()) { + if (key.startsWith("T")) { + // Text identification frame + auto newFrame = new TagLib::ID3v2::TextIdentificationFrame(key.toCString(true), TagLib::String::UTF8); + TagLib::StringList values; + + // Split value by vertical tab + int pos = 0; + while (pos != -1) { + int nextPos = value.find("\v", pos); + if (nextPos == -1) { + values.append(value.substr(pos)); + break; + } else { + values.append(value.substr(pos, nextPos - pos)); + pos = nextPos + 1; + } + } + + newFrame->setText(values); + id3v2Tag->addFrame(newFrame); + } + else if (key == "COMM") { + // Comments frame + auto newFrame = new TagLib::ID3v2::CommentsFrame(TagLib::String::UTF8); + newFrame->setText(value); + id3v2Tag->addFrame(newFrame); + } + // Add other frame types as needed + } + } + } + + // Save the file + return file.save(); +} + diff --git a/taglib.go b/taglib.go index 7825afa..153ba9c 100644 --- a/taglib.go +++ b/taglib.go @@ -143,6 +143,9 @@ const ( func ReadTags(path string) (map[string][]string, error) { var err error path, err = filepath.Abs(path) + + //fmt.Println("test, yes i'm here. 2") + if err != nil { return nil, fmt.Errorf("make path abs %w", err) } @@ -173,6 +176,82 @@ func ReadTags(path string) (map[string][]string, error) { return tags, nil } +// ReadID3v2Frames reads all ID3v2 frames from an MP3 file at the given path. +// This provides direct access to the raw ID3v2 frames, including custom frames like TXXX. +// The returned map has frame IDs as keys (like "TIT2", "TPE1", "TXXX") and frame data as values. +// For TXXX frames, the description is included in the key as "TXXX:description". +func ReadID3v2Frames(path string) (map[string][]string, error) { + var err error + path, err = filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("make path abs %w", err) + } + + dir := filepath.Dir(path) + mod, err := newModuleRO(dir) + if err != nil { + return nil, fmt.Errorf("init module: %w", err) + } + defer mod.close() + + var raw []string + if err := mod.call("taglib_file_id3v2_frames", &raw, wasmPath(path)); err != nil { + return nil, fmt.Errorf("call: %w", err) + } + if raw == nil { + return nil, ErrInvalidFile + } + + // If raw is empty, the file has no ID3v2 frames + var frames = map[string][]string{} + for _, row := range raw { + parts := strings.SplitN(row, "\t", 2) + if len(parts) != 2 { + continue + } + frames[parts[0]] = append(frames[parts[0]], parts[1]) + } + return frames, nil +} + +// ReadID3v1Frames reads all ID3v1 tags from an MP3 file at the given path. +// This provides access to the standard ID3v1 fields: title, artist, album, year, comment, track, and genre. +// The returned map has standardized keys (like "TITLE", "ARTIST", "ALBUM") and values. +// Note that ID3v1 is a much simpler format than ID3v2 with a fixed set of fields. +func ReadID3v1Frames(path string) (map[string][]string, error) { + var err error + path, err = filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("make path abs %w", err) + } + + dir := filepath.Dir(path) + mod, err := newModuleRO(dir) + if err != nil { + return nil, fmt.Errorf("init module: %w", err) + } + defer mod.close() + + var raw []string + if err := mod.call("taglib_file_id3v1_tags", &raw, wasmPath(path)); err != nil { + return nil, fmt.Errorf("call: %w", err) + } + if raw == nil { + return nil, ErrInvalidFile + } + + // If raw is empty, the file has no ID3v1 tags + var frames = map[string][]string{} + for _, row := range raw { + parts := strings.SplitN(row, "\t", 2) + if len(parts) != 2 { + continue + } + frames[parts[0]] = append(frames[parts[0]], parts[1]) + } + return frames, nil +} + // Properties contains the audio properties of a media file. type Properties struct { // Length is the duration of the audio @@ -263,6 +342,58 @@ func WriteTags(path string, tags map[string][]string, opts WriteOption) error { return nil } +// WriteID3v2Frames writes ID3v2 frames to an MP3 file at the given path. +// This provides direct access to modify raw ID3v2 frames, including custom frames like TXXX. +// The map should have frame IDs as keys (like "TIT2", "TPE1", "TXXX") and frame data as values. +// The opts parameter can include taglib.Clear to remove all existing frames not in the new map. +func WriteID3v2Frames(path string, frames map[string][]string, opts WriteOption) error { + var err error + path, err = filepath.Abs(path) + if err != nil { + return fmt.Errorf("make path abs %w", err) + } + + // Check if file exists and is readable + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("file stat error: %w", err) + } + + // Try to open the file to ensure it's not locked + file, err := os.OpenFile(path, os.O_RDONLY, 0) + if err != nil { + return fmt.Errorf("file open error: %w", err) + } + file.Close() + + dir := filepath.Dir(path) + mod, err := newModule(dir) + if err != nil { + return fmt.Errorf("init module: %w", err) + } + defer mod.close() + + // Convert the frames map to a slice of strings + var framesList []string + for k, vs := range frames { + framesList = append(framesList, fmt.Sprintf("%s\t%s", k, strings.Join(vs, "\v"))) + } + + var out bool + if err := mod.call("taglib_file_write_id3v2_frames", &out, wasmPath(path), framesList, uint8(opts)); err != nil { + return fmt.Errorf("call: %w", err) + } + if !out { + return ErrSavingFile + } + + return nil +} + +// WriteID3v1Frames Shouldn't be needed. WriteTags will write to ID3v1 if the file has it. +// WriteID3v2Frames is provided because there are some ID3v2 frames that aren't included in +// WriteTags. +// func WriteID3v1Frames() + type rc struct { wazero.Runtime wazero.CompiledModule diff --git a/taglib.wasm b/taglib.wasm index 3f3d642..dda741f 100755 Binary files a/taglib.wasm and b/taglib.wasm differ