diff --git a/Makefile.am b/Makefile.am index 7c0e6dfa7..bb6a0c53f 100644 --- a/Makefile.am +++ b/Makefile.am @@ -399,6 +399,7 @@ libutil_a_SOURCES = \ src/util/OptionParser.cxx src/util/OptionParser.hxx \ src/util/OptionDef.hxx \ src/util/ByteReverse.cxx src/util/ByteReverse.hxx \ + src/util/format.c src/util/format.h \ src/util/bit_reverse.c src/util/bit_reverse.h # Multi-threading library @@ -797,6 +798,7 @@ libtag_a_SOURCES =\ src/tag/TagPool.cxx src/tag/TagPool.hxx \ src/tag/TagTable.cxx src/tag/TagTable.hxx \ src/tag/Set.cxx src/tag/Set.hxx \ + src/tag/Format.cxx src/tag/Format.hxx \ src/tag/VorbisComment.cxx src/tag/VorbisComment.hxx \ src/tag/ReplayGain.cxx src/tag/ReplayGain.hxx \ src/tag/MixRamp.cxx src/tag/MixRamp.hxx \ diff --git a/NEWS b/NEWS index d70b04690..b0905abf3 100644 --- a/NEWS +++ b/NEWS @@ -15,6 +15,7 @@ ver 0.20 (not yet released) - jack: reduce CPU usage - pulse: set channel map to WAVE-EX - recorder: record tags + - recorder: allow dynamic file names * mixer - null: new plugin * reset song priority on playback diff --git a/doc/user.xml b/doc/user.xml index e5b89e27d..74c169c11 100644 --- a/doc/user.xml +++ b/doc/user.xml @@ -3091,6 +3091,50 @@ buffer_size: 16384 Write to this file. + + + + format_path + P + + + + An alternative to path which + provides a format string referring to tag values. + Every time a new song starts or a new tag gets + received from a radio station, a new file is + opened. If the format does not render a file + name, nothing is recorded. + + + + A tag name enclosed in percent signs ('%') is + replaced with the tag value. Example: + ~/.mpd/recorder/%artist% - + %title%.ogg + + + + Square brackets can be used to group a substring. + If none of the tags referred in the group can be + found, the whole group is omitted. Example: + [~/.mpd/recorder/[%artist% - + ]%title%.ogg] (this omits the dash + when no artist tag exists; if title also doesn't + exist, no file is written) + + + + The operators "|" (logical "or") and "&" + (logical "and") can be used to select portions of + the format string depending on the existing tag + values. Example: + ~/.mpd/recorder/[%title|%name%].ogg + (use the "name" tag if no title exists) + + + + encoder diff --git a/src/output/plugins/RecorderOutputPlugin.cxx b/src/output/plugins/RecorderOutputPlugin.cxx index 973e60b47..b0488080a 100644 --- a/src/output/plugins/RecorderOutputPlugin.cxx +++ b/src/output/plugins/RecorderOutputPlugin.cxx @@ -21,17 +21,23 @@ #include "RecorderOutputPlugin.hxx" #include "../OutputAPI.hxx" #include "../Wrapper.hxx" +#include "tag/Format.hxx" #include "encoder/ToOutputStream.hxx" #include "encoder/EncoderInterface.hxx" #include "encoder/EncoderPlugin.hxx" #include "encoder/EncoderList.hxx" #include "config/ConfigError.hxx" +#include "config/ConfigPath.hxx" #include "Log.hxx" #include "fs/AllocatedPath.hxx" #include "fs/io/FileOutputStream.hxx" #include "util/Error.hxx" +#include "util/Domain.hxx" #include +#include + +static constexpr Domain recorder_domain("recorder"); class RecorderOutput { friend struct AudioOutputWrapper; @@ -48,6 +54,18 @@ class RecorderOutput { */ AllocatedPath path; + /** + * A string that will be used with FormatTag() to build the + * destination path. + */ + std::string format_path; + + /** + * The #AudioFormat that is currently active. This is used + * for switching to another file. + */ + AudioFormat effective_audio_format; + /** * The destination file. */ @@ -84,10 +102,18 @@ class RecorderOutput { size_t Play(const void *chunk, size_t size, Error &error); private: + gcc_pure + bool HasDynamicPath() const { + return !format_path.empty(); + } + /** * Finish the encoder and commit the file. */ bool Commit(Error &error); + + void FinishFormat(); + bool ReopenFormat(AllocatedPath &&new_path, Error &error); }; inline bool @@ -105,9 +131,20 @@ RecorderOutput::Configure(const config_param ¶m, Error &error) } path = param.GetBlockPath("path", error); - if (path.IsNull()) { - if (!error.IsDefined()) - error.Set(config_domain, "'path' not configured"); + if (error.IsDefined()) + return false; + + const char *fmt = param.GetBlockValue("format_path", nullptr); + if (fmt != nullptr) + format_path = fmt; + + if (path.IsNull() && fmt == nullptr) { + error.Set(config_domain, "'path' not configured"); + return false; + } + + if (!path.IsNull() && fmt != nullptr) { + error.Set(config_domain, "Cannot have both 'path' and 'format_path'"); return false; } @@ -152,9 +189,19 @@ RecorderOutput::Open(AudioFormat &audio_format, Error &error) { /* create the output file */ - file = FileOutputStream::Create(path, error); - if (file == nullptr) - return false; + if (!HasDynamicPath()) { + assert(!path.IsNull()); + + file = FileOutputStream::Create(path, error); + if (file == nullptr) + return false; + } else { + /* don't open the file just yet; wait until we have + a tag that we can use to build the path */ + assert(path.IsNull()); + + file = nullptr; + } /* open the encoder */ @@ -163,10 +210,19 @@ RecorderOutput::Open(AudioFormat &audio_format, Error &error) return false; } - if (!EncoderToFile(error)) { + if (!HasDynamicPath()) { + if (!EncoderToFile(error)) { + encoder->Close(); + delete file; + return false; + } + } else { + /* remember the AudioFormat for ReopenFormat() */ + effective_audio_format = audio_format; + + /* close the encoder for now; it will be opened as + soon as we have received a tag */ encoder->Close(); - delete file; - return false; } return true; @@ -175,6 +231,8 @@ RecorderOutput::Open(AudioFormat &audio_format, Error &error) inline bool RecorderOutput::Commit(Error &error) { + assert(!path.IsNull()); + /* flush the encoder and write the rest to the file */ bool success = encoder_end(encoder, error) && @@ -195,14 +253,108 @@ RecorderOutput::Commit(Error &error) inline void RecorderOutput::Close() { + if (file == nullptr) { + /* not currently encoding to a file; nothing needs to + be done now */ + assert(HasDynamicPath()); + assert(path.IsNull()); + return; + } + Error error; if (!Commit(error)) LogError(error); + + if (HasDynamicPath()) { + assert(!path.IsNull()); + path.SetNull(); + } +} + +void +RecorderOutput::FinishFormat() +{ + assert(HasDynamicPath()); + + if (file == nullptr) + return; + + Error error; + if (!Commit(error)) + LogError(error); + + file = nullptr; + path.SetNull(); +} + +inline bool +RecorderOutput::ReopenFormat(AllocatedPath &&new_path, Error &error) +{ + assert(HasDynamicPath()); + assert(path.IsNull()); + assert(file == nullptr); + + FileOutputStream *new_file = + FileOutputStream::Create(new_path, error); + if (new_file == nullptr) + return false; + + AudioFormat new_audio_format = effective_audio_format; + if (!encoder->Open(new_audio_format, error)) { + delete new_file; + return false; + } + + /* reopening the encoder must always result in the same + AudioFormat as before */ + assert(new_audio_format == effective_audio_format); + + if (!EncoderToOutputStream(*new_file, *encoder, error)) { + encoder->Close(); + delete new_file; + return false; + } + + path = std::move(new_path); + file = new_file; + + FormatDebug(recorder_domain, "Recording to \"%s\"", path.c_str()); + + return true; } inline void RecorderOutput::SendTag(const Tag &tag) { + if (HasDynamicPath()) { + char *p = FormatTag(tag, format_path.c_str()); + if (p == nullptr || *p == 0) { + /* no path could be composed with this tag: + don't write a file */ + free(p); + FinishFormat(); + return; + } + + Error error; + AllocatedPath new_path = ParsePath(p, error); + free(p); + if (new_path.IsNull()) { + LogError(error); + FinishFormat(); + return; + } + + if (new_path != path) { + FinishFormat(); + + if (!ReopenFormat(std::move(new_path), error)) { + LogError(error); + return; + } + } + } + Error error; if (!encoder_pre_tag(encoder, error) || !EncoderToFile(error) || @@ -213,6 +365,14 @@ RecorderOutput::SendTag(const Tag &tag) inline size_t RecorderOutput::Play(const void *chunk, size_t size, Error &error) { + if (file == nullptr) { + /* not currently encoding to a file; discard incoming + data */ + assert(HasDynamicPath()); + assert(path.IsNull()); + return size; + } + return encoder_write(encoder, chunk, size, error) && EncoderToFile(error) ? size : 0; diff --git a/src/tag/Format.cxx b/src/tag/Format.cxx new file mode 100644 index 000000000..3fdcc7db6 --- /dev/null +++ b/src/tag/Format.cxx @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2003-2015 The Music Player Daemon Project + * http://www.musicpd.org + * + * 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. + * + * 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. + */ + +#include "config.h" +#include "Format.hxx" +#include "Tag.hxx" +#include "util/format.h" +#include "util/StringUtil.hxx" + +#include + +#include + +struct FormatTagContext { + const Tag &tag; + + char buffer[256]; + + explicit FormatTagContext(const Tag &_tag):tag(_tag) {} +}; + +/** + * Is this a character unsafe to use in a path name segment? + */ +static constexpr bool +IsUnsafeChar(char ch) +{ + return + /* disallow characters illegal in file names on + Windows (Linux allows almost anything) */ + ch == '\\' || ch == '/' || ch == ':' || ch == '*' || + ch == '?' || ch == '<' || ch == '>' || ch == '|' || + /* allow space, but disallow all other whitespace */ + (unsigned char)ch < 0x20; +} + +gcc_pure +static bool +HasUnsafeChar(const char *s) +{ + for (; *s; ++s) + if (IsUnsafeChar(*s)) + return true; + + return false; +} + +static const char * +SanitizeString(const char *s, char *buffer, size_t buffer_size) +{ + /* skip leading dots to avoid generating "../" sequences */ + while (*s == '.') + ++s; + + if (!HasUnsafeChar(s)) + return s; + + char *end = CopyString(buffer, s, buffer_size); + std::replace_if(buffer, end, IsUnsafeChar, ' '); + return buffer; +} + +gcc_pure gcc_nonnull_all +static const char * +TagGetter(const void *object, const char *name) +{ + const auto &_ctx = *(const FormatTagContext *)object; + auto &ctx = const_cast(_ctx); + const Tag &tag = ctx.tag; + + TagType tag_type = tag_name_parse_i(name); + if (tag_type == TAG_NUM_OF_ITEM_TYPES) + /* unknown tag name */ + return nullptr; + + const char *value = tag.GetValue(tag_type); + if (value == nullptr) + /* known tag name, but not present in this object */ + value = ""; + + // TODO: handle multiple tag values + return SanitizeString(value, ctx.buffer, sizeof(ctx.buffer)); +} + +char * +FormatTag(const Tag &tag, const char *format) +{ + FormatTagContext ctx(tag); + return format_object(format, &ctx, TagGetter); +} diff --git a/src/tag/Format.hxx b/src/tag/Format.hxx new file mode 100644 index 000000000..a08e687d0 --- /dev/null +++ b/src/tag/Format.hxx @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2003-2015 The Music Player Daemon Project + * http://www.musicpd.org + * + * 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. + * + * 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. + */ + +#ifndef MPD_TAG_FORMAT_HXX +#define MPD_TAG_FORMAT_HXX + +#include "check.h" +#include "Compiler.h" + +struct Tag; + +gcc_malloc gcc_nonnull_all +char * +FormatTag(const Tag &tag, const char *format); + +#endif diff --git a/src/util/format.c b/src/util/format.c new file mode 100644 index 000000000..d360df3dc --- /dev/null +++ b/src/util/format.c @@ -0,0 +1,252 @@ +/* + * music player command (mpc) + * Copyright (C) 2003-2015 The Music Player Daemon Project + * http://www.musicpd.org + * + * 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. + * + * 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. + */ + +#include "format.h" + +#include +#include +#include +#include + +/** + * Reallocate the given string and append the source string. + */ +gcc_malloc +static char * +string_append(char *dest, const char *src, size_t len) +{ + size_t destlen = dest != NULL + ? strlen(dest) + : 0; + + dest = realloc(dest, destlen + len + 1); + memcpy(dest + destlen, src, len); + dest[destlen + len] = '\0'; + + return dest; +} + +/** + * Skip the format string until the current group is closed by either + * '&', '|' or ']' (supports nesting). + */ +gcc_pure +static const char * +skip_format(const char *p) +{ + unsigned stack = 0; + + while (*p != '\0') { + if (*p == '[') + stack++; + else if (*p == '#' && p[1] != '\0') + /* skip escaped stuff */ + ++p; + else if (stack > 0) { + if (*p == ']') + --stack; + } else if (*p == '&' || *p == '|' || *p == ']') + break; + + ++p; + } + + return p; +} + +static char * +format_object2(const char *format, const char **last, const void *object, + const char *(*getter)(const void *object, const char *name)) +{ + char *ret = NULL; + const char *p; + bool found = false; + + for (p = format; *p != '\0';) { + switch (p[0]) { + case '|': + ++p; + if (!found) { + /* nothing found yet: try the next + section */ + free(ret); + ret = NULL; + } else + /* already found a value: skip the + next section */ + p = skip_format(p); + break; + + case '&': + ++p; + if (!found) + /* nothing found yet, so skip this + section */ + p = skip_format(p); + else + /* we found something yet, but it will + only be used if the next section + also found something, so reset the + flag */ + found = false; + break; + + case '[': { + char *t = format_object2(p + 1, &p, object, getter); + if (t != NULL) { + ret = string_append(ret, t, strlen(t)); + free(t); + found = true; + } + } + break; + + case ']': + if (last != NULL) + *last = p + 1; + if (!found) { + free(ret); + ret = NULL; + } + return ret; + + case '\\': { + /* take care of escape sequences */ + char ltemp; + switch (p[1]) { + case 'a': + ltemp = '\a'; + break; + + case 'b': + ltemp = '\b'; + break; + + case 't': + ltemp = '\t'; + break; + + case 'n': + ltemp = '\n'; + break; + + case 'v': + ltemp = '\v'; + break; + + case 'f': + ltemp = '\f'; + break; + + case 'r': + ltemp = '\r'; + break; + + case '[': + case ']': + ltemp = p[1]; + break; + + default: + /* unknown escape: copy the + backslash */ + ltemp = p[0]; + --p; + break; + } + + ret = string_append(ret, <emp, 1); + p += 2; + } + break; + + case '%': { + /* find the extent of this format specifier + (stop at \0, ' ', or esc) */ + const char *end = p + 1; + while (*end >= 'a' && *end <= 'z') + ++end; + + const size_t length = end - p + 1; + + if (*end != '%') { + ret = string_append(ret, p, length - 1); + p = end; + continue; + } + + char name[32]; + if (length > (int)sizeof(name)) { + ret = string_append(ret, p, length); + p = end + 1; + continue; + } + + memcpy(name, p + 1, length - 2); + name[length - 2] = 0; + + const char *value = getter(object, name); + size_t value_length; + if (value != NULL) { + if (*value != 0) + found = true; + value_length = strlen(value); + } else { + /* unknown variable: copy verbatim + from format string */ + value = p; + value_length = length; + } + + ret = string_append(ret, value, value_length); + + /* advance past the specifier */ + p = end + 1; + } + break; + + case '#': + /* let the escape character escape itself */ + if (p[1] != '\0') { + ret = string_append(ret, p + 1, 1); + p += 2; + break; + } + + /* fall through */ + + default: + /* pass-through non-escaped portions of the format string */ + ret = string_append(ret, p, 1); + ++p; + } + } + + if (last != NULL) + *last = p; + return ret; +} + +char * +format_object(const char *format, const void *object, + const char *(*getter)(const void *object, const char *name)) +{ + return format_object2(format, NULL, object, getter); +} diff --git a/src/util/format.h b/src/util/format.h new file mode 100644 index 000000000..fa3624b51 --- /dev/null +++ b/src/util/format.h @@ -0,0 +1,51 @@ +/* + * music player command (mpc) + * Copyright (C) 2003-2015 The Music Player Daemon Project + * http://www.musicpd.org + * + * 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. + * + * 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. + */ + +#ifndef MPC_FORMAT_H +#define MPC_FORMAT_H + +#include "Compiler.h" + +struct mpd_song; + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Pretty-print an object into a string using the given format + * specification. + * + * @param format the format string + * @param object the object + * @param getter a getter function that extracts a value from the object + * @return the resulting string to be freed by free(); NULL if + * no format string group produced any output + */ +gcc_malloc +char * +format_object(const char *format, const void *object, + const char *(*getter)(const void *object, const char *name)); + +#ifdef __cplusplus +} +#endif + +#endif