├── .gitignore ├── LICENSE ├── README.md ├── bin └── taffy └── taffy.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2024 Casey Mulcahy (jangler) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Taffy 2 | ===== 3 | Taffy is a command-line tool for reading and writing audio metadata, as 4 | supported by [TagLib](http://taglib.github.io/). That means it can edit 5 | tags for MP3, Ogg Vorbis, FLAC, WAV, and MP4 files, along with several 6 | other file formats. 7 | 8 | Installation 9 | ------------ 10 | If installing via `gem`, you must have already installed your 11 | distribution's TagLib package, usually called `taglib`, `taglib-devel`, 12 | or `libtag1-dev`. Then run: 13 | 14 | gem install taffy 15 | 16 | If you use Arch Linux or a derivative, you may also install via the [AUR 17 | package](https://aur.archlinux.org/packages/taffy/). 18 | 19 | Usage 20 | ----- 21 | Usage: taffy [options] file ... 22 | 23 | Tag options: 24 | -l, --album ALBUM Set album tag 25 | -r, --artist ARTIST Set artist tag 26 | -c, --comment COMMENT Set comment tag 27 | -g, --genre GENRE Set genre tag 28 | -t, --title TITLE Set title tag 29 | -n, --track TRACK Set track tag 30 | -y, --year YEAR Set year tag 31 | --no-album Clear album tag 32 | --no-artist Clear artist tag 33 | --no-comment Clear comment tag 34 | --no-genre Clear genre tag 35 | --no-title Clear title tag 36 | --no-track Clear track tag 37 | --no-year Clear year tag 38 | --clear Clear all tags 39 | 40 | Filename options: 41 | --extract SPEC Extract tags from filename 42 | --rename SPEC Rename file based on tags 43 | --rename-fs SPEC Like --rename; see below 44 | 45 | If no options are given, file tags are printed instead. 46 | 47 | In a filename spec, a sequence such as %R or %r stands for 48 | the corresponding tag, in this case the artist name. In a 49 | filename, %R leaves letter case intact, while %r downcases 50 | the tag. A sequence such as %_t maps special characters in 51 | the tag to the given substitute, in this case an underscore. 52 | --rename remaps all characters that need to be escaped in 53 | the shell, while --rename-fs remaps only characters that 54 | are invalid in filenames. 55 | 56 | Other options: 57 | -h, --help Show this message and exit 58 | --version Show version and exit 59 | 60 | Examples 61 | -------- 62 | Print tags from an audio file: 63 | 64 | taffy song.mp3 65 | 66 | Tag a series of files with an artist, album, and year: 67 | 68 | taffy -r Deerhoof -l "The Man, The King, The Girl" -y 1997 *.mp3 69 | 70 | Tag an audio file, then rename it to "14 - Queen of the Mole People.mp3": 71 | 72 | taffy -n 14 -t "Queen of the Mole People" --rename-fs "%n - %T" song.mp3 73 | 74 | Etymology 75 | --------- 76 | Taffy tags audio files for you. 77 | -------------------------------------------------------------------------------- /bin/taffy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'optparse' 4 | require 'shellwords' 5 | 6 | require 'taglib' 7 | 8 | VERSION = [1, 4, 3] 9 | 10 | FIELDS = [ 11 | ['l', 'album', String], 12 | ['r', 'artist', String], 13 | ['c', 'comment', String], 14 | ['g', 'genre', String], 15 | ['t', 'title', String], 16 | ['n', 'track', Integer], 17 | ['y', 'year', Integer], 18 | ] 19 | 20 | RENAME_CHARS = /[ `~!#$%^&*()=[{}\\|;:",<>\/?]|\\]/ 21 | RENAME_FS_CHARS = /[<>:"\/\\|?*]/ 22 | 23 | actions = [] 24 | extract_spec = rename_spec = special_chars = nil 25 | $status = 0 26 | 27 | options = OptionParser.new do |opts| 28 | opts.banner += " file ..." 29 | 30 | opts.separator "" 31 | opts.separator "Tag options:" 32 | 33 | FIELDS.each do |f| 34 | short = "-#{f[0]}#{f[1].upcase}" 35 | long = "--#{f[1]} #{f[1].upcase}" 36 | opts.on(short, long, String, "Set #{f[1]} tag") do |x| 37 | begin 38 | value = f[2] == Integer ? Integer(x, 10) : x 39 | rescue ArgumentError => err 40 | warn(err) 41 | exit(1) 42 | end 43 | actions << ->(r) { r.send("#{f[1]}=", value) } 44 | end 45 | end 46 | FIELDS.each do |f| 47 | long = "--no-#{f[1]}" 48 | opts.on(long, "Clear #{f[1]} tag") do 49 | actions << ->(r) { r.send("#{f[1]}=", f[2] == String ? nil : 0) } 50 | end 51 | end 52 | opts.on("--clear", "Clear all tags") do 53 | FIELDS.each do |f| 54 | actions << ->(r) { r.send("#{f[1]}=", f[2] == String ? nil : 0) } 55 | end 56 | end 57 | 58 | opts.separator "" 59 | opts.separator "Filename options:" 60 | 61 | opts.on("--extract SPEC", String, "Extract tags from filename") do |spec| 62 | extract_spec = spec 63 | end 64 | opts.on("--rename SPEC", String, "Rename file based on tags") do |spec| 65 | rename_spec = spec 66 | special_chars = RENAME_CHARS 67 | end 68 | opts.on("--rename-fs SPEC", String, "Like --rename; see below") do |spec| 69 | rename_spec = spec 70 | special_chars = RENAME_FS_CHARS 71 | end 72 | 73 | opts.separator "" 74 | opts.separator "If no options are given, file tags are printed instead." 75 | opts.separator "" 76 | opts.separator "In a filename spec, a sequence such as %R or %r stands for" 77 | opts.separator "the corresponding tag, in this case the artist name. In a" 78 | opts.separator "filename, %R leaves letter case intact, while %r downcases" 79 | opts.separator "the tag. A sequence such as %_t maps special characters in" 80 | opts.separator "the tag to the given substitute, in this case an underscore." 81 | opts.separator "--rename remaps all characters that need to be escaped in" 82 | opts.separator "the shell, while --rename-fs remaps only characters that" 83 | opts.separator "are invalid in filenames." 84 | opts.separator "" 85 | opts.separator "Other options:" 86 | 87 | opts.on_tail("-h", "--help", "Show this message and exit") do 88 | puts opts 89 | exit 90 | end 91 | 92 | opts.on_tail("--version", "Show version and exit") do 93 | puts "taffy #{VERSION.join('.')}" 94 | exit 95 | end 96 | end 97 | 98 | begin 99 | options.parse! 100 | rescue OptionParser::ParseError => err 101 | warn(err) 102 | exit(1) 103 | end 104 | 105 | if ARGV.empty? 106 | puts options 107 | exit(1) 108 | end 109 | 110 | def print_info(filename, tag) 111 | puts filename 112 | 113 | FIELDS.each do |f| 114 | value = tag.send(f[1]) 115 | puts "#{f[1]}:#{' ' * (8 - f[1].size)}#{value}" if value && value != 0 116 | end 117 | 118 | puts 119 | end 120 | 121 | def extract(tag, spec, filename) 122 | name = File.basename(filename) 123 | name = name.slice(0, name.size - File.extname(name).size) 124 | regexp = Regexp.new(spec.gsub(/%(\W|_)?([lrcgt])/, '(.+)'). 125 | gsub(/%(\W|_)?([ny])/, '(\d+)')) 126 | if filematch = regexp.match(name) 127 | i = 1 128 | loop do 129 | specmatch = /%(?:\W|_)?([lrcgtny])/.match(spec) 130 | break unless specmatch 131 | FIELDS.each do |f| 132 | next if f[0] != specmatch[1] 133 | val = 'ny'.include?(f[0]) ? filematch[i].to_i : filematch[i] 134 | tag.send("#{f[1]}=", val) 135 | end 136 | spec = specmatch.post_match 137 | i += 1 138 | end 139 | else 140 | warn("#{filename} did not match extraction spec") 141 | $status = 2 142 | end 143 | end 144 | 145 | def strip_special_chars(str, subchar, special_chars) 146 | # removing single quotes is usually better than remapping them 147 | str.delete(special_chars == RENAME_CHARS ? "'" : '') 148 | .gsub(special_chars, subchar).gsub(/(#{subchar})+/, subchar) 149 | .gsub(/(^#{subchar}|#{subchar}$)/, '') || '_' 150 | end 151 | 152 | def rename(tag, spec, special_chars) 153 | spec = spec.dup 154 | 155 | FIELDS.each do |f| 156 | tag_string = tag.send(f[1]).to_s 157 | tag_string = tag_string.rjust(2, '0') if f[1] == 'track' 158 | spec.gsub!(/%(\W|_)?(#{f[0]}|#{f[0].upcase})/) do |match| 159 | sub = $2 == f[0] ? tag_string.downcase : tag_string 160 | strip_special_chars(sub, $1 || '', special_chars) 161 | end 162 | end 163 | 164 | spec 165 | end 166 | 167 | ARGV.each do |filename| 168 | TagLib::FileRef.open(filename) do |fileref| 169 | if fileref.null? 170 | warn("Could not open file: #{filename}") 171 | $status = 2 172 | next 173 | end 174 | 175 | if extract_spec 176 | extract(fileref.tag, extract_spec, filename) 177 | fileref.save 178 | end 179 | 180 | if actions.empty? and !(extract_spec or rename_spec) 181 | print_info(filename, fileref.tag) 182 | elsif !actions.empty? 183 | actions.each { |action| action.call(fileref.tag) } 184 | fileref.save 185 | end 186 | 187 | if rename_spec 188 | name = rename(fileref.tag, rename_spec, special_chars) + 189 | File.extname(filename) 190 | path = File.join(File.dirname(filename), name) 191 | if File.exist?(path) 192 | warn("Cannot rename; file exists: #{name}") 193 | $status = 2 194 | else 195 | File.rename(filename, path) 196 | end 197 | end 198 | end 199 | end 200 | 201 | exit($status) 202 | -------------------------------------------------------------------------------- /taffy.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'taffy' 3 | s.version = '1.4.3' 4 | s.date = '2024-12-18' 5 | s.summary = 'A command-line audio tagging tool' 6 | s.description = "A command-line tool for reading and writing audio \ 7 | metadata.".squeeze(' ') 8 | s.authors = ['Casey Mulcahy'] 9 | s.email = 'caseymulcahy@proton.me' 10 | s.files = `git ls-files`.split 11 | s.homepage = 'https://github.com/jangler/taffy' 12 | s.license = 'MIT' 13 | 14 | s.executables << 'taffy' 15 | s.add_runtime_dependency 'taglib-ruby', '>= 1.1', '< 3' 16 | s.required_ruby_version = '>= 2.0.0' 17 | end 18 | --------------------------------------------------------------------------------