diff --git a/src/tangara/database/database.hpp b/src/tangara/database/database.hpp index 6daffd23..18070353 100644 --- a/src/tangara/database/database.hpp +++ b/src/tangara/database/database.hpp @@ -37,7 +37,7 @@ namespace database { -const uint8_t kCurrentDbVersion = 6; +const uint8_t kCurrentDbVersion = 7; struct SearchKey; class Record; diff --git a/src/tangara/database/tag_parser.cpp b/src/tangara/database/tag_parser.cpp index d377adb1..a6a25555 100644 --- a/src/tangara/database/tag_parser.cpp +++ b/src/tangara/database/tag_parser.cpp @@ -6,14 +6,20 @@ #include "database/tag_parser.hpp" +#include #include #include +#include #include +#include #include +#include "database/track.hpp" +#include "debug.hpp" #include "drivers/spi.hpp" #include "esp_log.h" #include "ff.h" +#include "ogg/ogg.h" #include "tags.h" #include "memory_resource.hpp" @@ -106,10 +112,18 @@ static void toc(Tagctx* ctx, int ms, int offset) {} static const std::size_t kBufSize = 1024; [[maybe_unused]] static const char* kTag = "TAGS"; -TagParserImpl::TagParserImpl() {} +TagParserImpl::TagParserImpl() { + parsers_.emplace_back(new OggTagParser()); + parsers_.emplace_back(new GenericTagParser()); +} auto TagParserImpl::ReadAndParseTags(std::string_view path) -> std::shared_ptr { + if (path.empty()) { + return {}; + } + + // Check the cache first to see if we can skip parsing this file completely. { std::lock_guard lock{cache_mutex_}; std::optional> cached = @@ -119,7 +133,15 @@ auto TagParserImpl::ReadAndParseTags(std::string_view path) } } - std::shared_ptr tags = parseNew(path); + // Nothing in the cache; try each of our parsers. + std::shared_ptr tags; + for (auto& parser : parsers_) { + tags = parser->ReadAndParseTags(path); + if (tags) { + break; + } + } + if (!tags) { return {}; } @@ -135,6 +157,7 @@ auto TagParserImpl::ReadAndParseTags(std::string_view path) } } + // Store the result in the cache for later. { std::lock_guard lock{cache_mutex_}; cache_.Put({path.data(), path.size(), &memory::kSpiRamResource}, tags); @@ -143,7 +166,148 @@ auto TagParserImpl::ReadAndParseTags(std::string_view path) return tags; } -auto TagParserImpl::parseNew(std::string_view p) -> std::shared_ptr { +OggTagParser::OggTagParser() { + nameToTag_["TITLE"] = Tag::kTitle; + nameToTag_["ALBUM"] = Tag::kAlbum; + nameToTag_["ARTIST"] = Tag::kArtist; + nameToTag_["ALBUMARTIST"] = Tag::kAlbumArtist; + nameToTag_["TRACKNUMBER"] = Tag::kTrack; + nameToTag_["GENRE"] = Tag::kGenres; + nameToTag_["DISC"] = Tag::kDisc; +} + +auto OggTagParser::ReadAndParseTags(std::string_view p) + -> std::shared_ptr { + if (!p.ends_with(".ogg") && !p.ends_with(".opus") && !p.ends_with(".ogx")) { + return {}; + } + ogg_sync_state sync; + ogg_sync_init(&sync); + + ogg_page page; + ogg_stream_state stream; + bool stream_init = false; + + std::string path{p}; + FIL file; + if (f_open(&file, path.c_str(), FA_READ) != FR_OK) { + ESP_LOGW(kTag, "failed to open file '%s'", path.c_str()); + return {}; + } + + std::shared_ptr tags; + + // The comments packet is the second in the stream. This is *usually* the + // second page, sometimes overflowing onto the third page. There is no + // guarantee of this however, so we read the first five pages before giving + // up just in case. We don't try to read more pages than this as it could take + // quite some time, with no likely benefit. + for (int i = 0; i < 5; i++) { + // Load up the sync with data until we have a complete page. + while (ogg_sync_pageout(&sync, &page) != 1) { + char* buffer = ogg_sync_buffer(&sync, 512); + + UINT br; + FRESULT fres = f_read(&file, buffer, 512, &br); + if (fres != FR_OK || br == 0) { + goto finish; + } + + int res = ogg_sync_wrote(&sync, br); + if (res != 0) { + goto finish; + } + } + + // Ensure the stream has the correct serialno. pagein and packetout both + // give no results if the serialno is incorrect. + if (ogg_page_bos(&page)) { + ogg_stream_init(&stream, ogg_page_serialno(&page)); + stream_init = true; + } + + if (ogg_stream_pagein(&stream, &page) < 0) { + goto finish; + } + + // Try to pull out a packet. + ogg_packet packet; + if (ogg_stream_packetout(&stream, &packet) == 1) { + // We're interested in the second packet (packetno == 1) only. + if (packet.packetno < 1) { + continue; + } + if (packet.packetno > 1) { + goto finish; + } + + tags = TrackTags::create(); + if (memcmp(packet.packet, "OpusTags", 8) == 0) { + std::span data{packet.packet, + static_cast(packet.bytes)}; + tags->encoding(Container::kOpus); + parseComments(*tags, data.subspan(8)); + } else if (packet.packet[0] == 3 && + memcmp(packet.packet + 1, "vorbis", 6) == 0) { + std::span data{packet.packet, + static_cast(packet.bytes)}; + tags->encoding(Container::kOgg); + parseComments(*tags, data.subspan(7)); + } + break; + } + } + +finish: + if (stream_init) { + ogg_stream_clear(&stream); + } + ogg_sync_clear(&sync); + f_close(&file); + + return tags; +} + +auto OggTagParser::parseComments(TrackTags& res, std::span data) + -> void { + uint64_t vendor_len = parseLength(data); + uint64_t num_tags = parseLength(data.subspan(4 + vendor_len)); + + data = data.subspan(4 + vendor_len + 4); + for (size_t i = 0; i < num_tags; i++) { + uint64_t size = parseLength(data); + + std::string_view tag = { + reinterpret_cast(data.subspan(4).data()), + static_cast(size)}; + + auto split = tag.find("="); + + if (split != std::string::npos) { + std::string_view key = tag.substr(0, split); + std::string_view val = tag.substr(split + 1); + + std::string key_upper{key}; + std::transform(key.begin(), key.end(), key_upper.begin(), ::toupper); + + if (nameToTag_.contains(key_upper) && !val.empty()) { + res.set(nameToTag_[key_upper], val); + } + } + + data = data.subspan(4 + size); + } +} + +auto OggTagParser::parseLength(std::span data) -> uint64_t { + return static_cast(data[3]) << 24 | + static_cast(data[2]) << 16 | + static_cast(data[1]) << 8 | + static_cast(data[0]) << 0; +} + +auto GenericTagParser::ReadAndParseTags(std::string_view p) + -> std::shared_ptr { std::string path{p}; libtags::Aux aux; auto out = TrackTags::create(); @@ -151,7 +315,6 @@ auto TagParserImpl::parseNew(std::string_view p) -> std::shared_ptr { if (f_stat(path.c_str(), &aux.info) != FR_OK || f_open(&aux.file, path.c_str(), FA_READ) != FR_OK) { - ESP_LOGW(kTag, "failed to open file %s", path.c_str()); return {}; } diff --git a/src/tangara/database/tag_parser.hpp b/src/tangara/database/tag_parser.hpp index ccbc0ea9..642c4876 100644 --- a/src/tangara/database/tag_parser.hpp +++ b/src/tangara/database/tag_parser.hpp @@ -6,6 +6,7 @@ #pragma once +#include #include #include "database/track.hpp" @@ -27,7 +28,7 @@ class TagParserImpl : public ITagParser { -> std::shared_ptr override; private: - auto parseNew(std::string_view path) -> std::shared_ptr; + std::vector> parsers_; /* * Cache of tags that have already been extracted from files. Ideally this @@ -35,10 +36,25 @@ class TagParserImpl : public ITagParser { */ std::mutex cache_mutex_; util::LruCache<8, std::pmr::string, std::shared_ptr> cache_; +}; + +class OggTagParser : public ITagParser { + public: + OggTagParser(); + auto ReadAndParseTags(std::string_view path) + -> std::shared_ptr override; + + private: + auto parseComments(TrackTags&, std::span data) -> void; + auto parseLength(std::span data) -> uint64_t; + + std::unordered_map nameToTag_; +}; - // We could also consider keeping caches of artist name -> std::string and - // similar. This hasn't been done yet, as this isn't a common workload in - // any of our UI. +class GenericTagParser : public ITagParser { + public: + auto ReadAndParseTags(std::string_view path) + -> std::shared_ptr override; }; } // namespace database diff --git a/src/tangara/database/track.cpp b/src/tangara/database/track.cpp index 461f4561..cdb7543c 100644 --- a/src/tangara/database/track.cpp +++ b/src/tangara/database/track.cpp @@ -148,7 +148,7 @@ auto TrackTags::set(Tag t, std::string_view v) -> void { track(v); break; case Tag::kAlbumOrder: - // This tag is derices from disc and track, and so it can't be set. + // This tag is derived from disc and track, and so it can't be set. break; case Tag::kGenres: genres(v);