diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4177d71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +.idea +.project +.loadpath +.DS_Store +pkg +doc +vendor/ +vendor.noindex/ +.bundle/ +Gemfile.lock +bin/ +coverage/ +config/ +backup/ +scratch/ +.yardoc/ +spec/support/fixtures/albums +.rspec +*.gem diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a8a83a6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Added + +- Start using [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). +- A separate version file, lib/spfy/version.rb +- Lots of Yard docs. +- Specs. +- Command line options to control the meta-data of a playlist. +- Option to prettify the output via `tidy` + +### Changed + +- Start following [SemVer](http://semver.org) properly. +- Updated gemspec to current standards (2018) e.g. a separate version file, git ls-files etc +- Moved executable from bin/ to exe/ so as not to get caught up with binstubs. +- Using docopt for parsing, it's so much easier to work with than other opt-parsers. +- Moved from building up the XML line by line, tag by tag, to running it through ERB templates. +- More object orientated design. +- Some of the options for suppressing output. + +## [1.0.0] + +- A working product at this point. \ No newline at end of file diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..cbd1c52 --- /dev/null +++ b/Gemfile @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } + +gemspec + +group :development do + gem "rake" + gem "pry-byebug" + gem "pry-state" + gem "rb-readline" + gem "awesome_print" + gem "yard" + gem "yardstick" +end + + +group :test do + gem "rspec" + gem "rspec-its" + gem "simplecov" + gem "rspec-given" + gem "timecop", ">=0.9.1" +end diff --git a/README.md b/README.md index 48e4b65..38b1f74 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,17 @@ **Spfy** is a command-line tool for generating [XSPF](http://xspf.org/) playlists from metadata stored in several popular audio formats and is developed entirely in [Ruby](http://www.ruby-lang.org/). It takes one or more local directory paths as input, extracts metadata tags from any audio files that it encounters, and generates a valid XSPF playlist. ### Prerequisites -A working Ruby installation (version 1.9 or greater) is required for Spfy to work, but this is outside the scope of this guide. For more information refer to the [official installation procedure](http://www.ruby-lang.org/en/downloads/). +A working Ruby installation (version 2.0 or greater) is required for Spfy to work, but this is outside the scope of this guide. For more information refer to the [official installation procedure](http://www.ruby-lang.org/en/downloads/). [TagLib](http://developer.kde.org/~wheeler/taglib.html) is also required. Follow the steps below (taken from the [taglib-ruby installation guide](http://robinst.github.com/taglib-ruby/)) to install the necessary files for your respective system type: -| System: | Command: | -|---------------|------------------------------------| -| Debian/Ubuntu | `sudo apt-get install libtag1-dev` | -| Fedora/RHEL | `sudo yum install taglib-devel` | -| Brew | `brew install taglib` | -| MacPorts | `sudo port install taglib` | +| System: | Command: | +|---------------|------------------------------------| +| Debian/Ubuntu | `sudo apt-get install libtag1-dev` | +| Fedora/RHEL | `sudo yum install taglib-devel` | +| Brew | `brew install taglib` | +| MacPorts | `sudo port install taglib` | +| Pkgsrc | `(sudo) pkgin install taglib` | ### Installation With the prerequisites above taken care of Spfy can be installed with the following command: @@ -21,19 +22,23 @@ With the prerequisites above taken care of Spfy can be installed with the follow $ gem install spfy ### Using Spfy -By default, Spfy will output a formatted XSPF playlist to the standard output stream that will include _location_, _title_, _creator_, _album_, and _trackNum_ elements for each audio file where available. +By default, Spfy will output a formatted XSPF playlist to the standard output stream that will include _location_, _album_, _artist_, _comment_, _genre_, _title_, _trackNum_, and _year_ elements for each audio file where available. The general syntax for Spfy is `spfy [options] dir1 ... dirN`, where _dir1 ... dirN_ is one or more paths to directories containing audio files. For example: - $ spfy ~/music + $ spfy ~/music/"Smashing Pumpkins" -..will produce the following output (where ~/music contains one audio file with valid metadata): +..will produce the following output (where ~/music/Smashing\ Pumpkins contains one audio file with valid metadata): ```xml + Smashing Pumpkins + bobby (or whatever your username is) + 2018-03-11T06:49:16+00:00 + Created with Spfy.rb file:///Users/spfy/music/03%20A%20Stitch%20In%20Time.mp3 @@ -46,25 +51,55 @@ For example: ``` -Spfy supports multiple directory paths (e.g. `spfy /dir1 /dir2`) and traverses each directory recursively by default. Unsupported files and empty directories in a directory tree are silently ignored and will not impact Spfy's output. +Spfy supports multiple directory paths (e.g. `spfy /dir1 /dir2`) and traverses each directory recursively by default. Unsupported files and empty directories in a directory tree are silently ignored and will not impact Spfy's output. It will even take a single file as a target, or directories with sub-directories that hold files (no max depth has been set on the recursion so don't start too far up the directory tree;) Command-line arguments allow you to control which elements Spfy outputs: - -f, --no-location Suppress file location output - -t, --no-title Suppress track title in output - -a, --no-artist Suppress artist name in output - -l, --no-album Suppress album name in output - -n, --no-tracknum Suppress track number in output + --no-location Suppress file location output + --no-title Suppress track title in output + --no-artist Suppress artist name in output + --no-album Suppress album name in output + --no-trackNum Suppress track number in output - CASE SENSITIVE! + +You can also control the metadata for the playlist: + + -t TITLE --title=TITLE Playlist title + -c CREATOR --creator=CREATOR Playlist creator, defaults to env $USER + -d DATE --date=DATE Playlist creation date, defaults to now + -a NOTE --annotation=NOTE Playlist annotation, default: "Created with Spfy.rb" + +Specify an output file: + + -o FILE --output=FILE File to write to, otherwise output to STDOUT + --force Allow the overwriting of a file. + +Limit the number of tracks: + + --max-tracks NUM Limit the output to NUM tracks + +And prettify the output: + + --use-tidy Run the tidy command to prettify the output. + Uses `/usr/bin/command -v tidy` to find tidy and + `tidy -q -i -xml` to filter through. + +Although you could simply not specify an outfile and pipe through a filter of your choice to get the same effect. For additional options use `spfy --help`. ### License Spfy is free software, and you are welcome to redistribute it under certain conditions. See the [GNU General Public License](http://www.gnu.org/licenses/gpl.html) for more details. + +### Development + +Use Bundler to install the development dependencies and then run the Rake task to copy the audio files for the specs to run against. + + ### Acknowledgements Spfy uses the following third party software components: * [taglib-ruby](http://robinst.github.com/taglib-ruby/) by Robin Stocker ### Contact -Email me at [marc.ransome@fidgetbox.co.uk](mailto:marc.ransome@fidgetbox.co.uk) or tweet [@marcransome](http://www.twitter.com/marcransome). +Email me at [marc.ransome@fidgetbox.co.uk](mailto_marc_.ransome@fidgetbox.co.uk) or tweet [@marcransome](http://www.twitter.com/marcransome). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..dde6a5c --- /dev/null +++ b/Rakefile @@ -0,0 +1,26 @@ +require 'pathname' + +desc "Install the spec fixtures" +task :fixtures do + gem_home = Pathname(ENV["GEM_HOME"]) + files = gem_home.join("gems").glob("taglib-ruby-*/test/data/*").reject{|x| x.extname.to_s == ".cpp" } + # mk dir tree + albums_dir = Pathname(__dir__).join("spec/support/fixtures/albums") + albums_dir.mkpath + %w{flac mp3 mp4 oga wav}.each do |ext| + album = albums_dir.join(ext) + album.mkpath + # find taglib and copy files + # And yes, this is a horrible hack + if ext == "mp4" + exts = ["m4a","aiff"] + else + exts = [ext] + end + exts.each do |ext| + FileUtils.cp files.select{|x| x.extname.to_s == ".#{ext}" }, album + end + end + puts "Audio files copied from taglib to spec/support/fixtures/albums" + albums_dir.join("empty").mkpath # for the empty directory test +end \ No newline at end of file diff --git a/bin/spfy b/bin/spfy deleted file mode 100755 index 68b06de..0000000 --- a/bin/spfy +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env ruby - -require 'spfy' - -Spfy.parse_args -Spfy.generate_xml diff --git a/exe/spfy b/exe/spfy new file mode 100755 index 0000000..07e8c8f --- /dev/null +++ b/exe/spfy @@ -0,0 +1,90 @@ +#!/usr/bin/env ruby + +require 'spfy' +require 'spfy/version' +require 'docopt' +require 'pathname' + +EXE = Pathname(__FILE__).basename + +doc = < e + puts e.message + exit 0 +end + +output = options.delete "--output" + +# Remove extraneous +options.delete "--help" +options.delete "--version" +use_tidy = options.delete "--use-tidy" +force_ok = options.delete "--force" +WARNING_RED="tput setaf 1" +BACK_TO_NORMAL="tput sgr0" + +playlist = Spfy::Playlist.new options + +COMMAND = "/usr/bin/command" +DEFAULT_TIDY_FINDER="#{COMMAND} -v tidy" + +def filter xml, use_tidy=false + if use_tidy + require 'open3' + path = Pathname(`#{DEFAULT_TIDY_FINDER}`.chomp) + if path.exist? + stdout, _= Open3.capture2(path.to_path,"-q", "-i", "-xml", stdin_data: xml) + stdout + else + warn `#{WARNING_RED}` + "tidy not found at #{path.to_path}" + `#{BACK_TO_NORMAL}` + "\nRunning without tidying…\n\n" + xml + end + else + xml + end +end + +blk = ->(f){ f.puts filter(playlist.to_xml, use_tidy) } + +begin +if output + path = Pathname(output) + if path.exist? and not force_ok + warn `#{WARNING_RED}` + "#{path.to_path} exists. Use the --force option to overwrite" + `#{BACK_TO_NORMAL}` + else + File.open(path, "w", &blk) + end +else + blk.call(Kernel) +end +rescue => e + warn e.message +end \ No newline at end of file diff --git a/lib/spfy.rb b/lib/spfy.rb index d07104e..36b3e09 100755 --- a/lib/spfy.rb +++ b/lib/spfy.rb @@ -1,152 +1,330 @@ -# -# spfy.rb -# Spfy ("spiffy") -# -# Copyright (c) 2012, Marc Ransome -# -# This file is part of Spfy. -# -# Spfy 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 3 of the License, or -# (at your option) any later version. -# -# Spfy 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 Spfy. If not, see . -# - -require "spfy/optionreader" -require "optparse" -require "ostruct" require "taglib" -require "find" -require "uri" - -class Spfy - - VERSION = "1.0.0" - USAGE = "Use `#{File.basename($0)} --help` for available options." - - @xml_tags = { - :header => "\n"\ - "\n"\ - "\t\n", - :footer => "\t\n\n", - :title_start => "\t\t\t", - :title_end => "\n", - :artist_start => "\t\t\t", - :artist_end => "\n", - :album_start => "\t\t\t", - :album_end => "\n", - :location_start => "\t\t\tfile://", - :location_end => "\n", - :track_start => "\t\t\n", - :track_end => "\t\t\n", - :track_num_start => "\t\t\t", - :track_num_end => "\n" - } - - def self.parse_args - begin - if ARGV.empty? then - exit_with_banner +require 'tilt' +require 'pathname' +require 'time' +require 'addressable/uri' + +# Create XSPF playlist files +module Spfy + + # For tagging all exceptions that come through this library. + module Error; end + + + # Locations of the ERB templates + TEMPLATES = Pathname(__dir__).join("templates") + + # Produces the track entity + # @see http://xspf.org/xspf-v1.html#rfc.section.4.1.1.2.14.1.1 + class Track + + # The XSPF tags being targeted + XSPF_TAGS = [:location, :album, :creator, :annotation, :title, :trackNum].freeze + + # The translation of XSPF tags and Taglib tags + XSPF_TO_TAGLIB = { + :album => :album, + :creator => :artist, + :annotation => :comment, + :title => :title, + :trackNum => :track, + }.freeze + + # Mainly for translating options into XSPF for noes + TAGLIB_TO_XSPF = { + :track => :trackNum, + :title => :title, + :comment => :annotation, + :artist => :creator, + :album => :album, + :location => :location + }.freeze + + # @param [String,Pathname] path The location of the track. + # @param [Hash] options + # @api public + # @example + # Spfy::Track.new(pn, options: @options) + def initialize path, options: {} + @path = Pathname(path).expand_path + @template = Tilt::ERBTemplate.new(TEMPLATES.join("track.xml.erb")) + @processed = false + @options = options.dup + parse @options + @available_tags = XSPF_TAGS - @noes + @data_klass = Struct.new *@available_tags + @data = @data_klass.new + # @see http://xspf.org/xspf-v1.html#rfc.section.4.1.1.2.5 + if @location.nil? and @available_tags.include?(:location) + path = @path.absolute? ? @path : @path.realpath + @location = Addressable::URI.join('file:///', Addressable::URI.escape(path.to_path) ) + @data.location = @location end - - # parse command-line arguments - @options = OptionReader.parse(ARGV) - - # test for zero source paths - if @options.dirs.empty? - exit_with_message("No source path(s) specified.") + + process + end + + + # If there's a way to do dynamic delegation using Forwadable + # I don't know what it is. Hence this. + # @api private + # @see https://ruby-doc.org/core-2.5.0/BasicObject.html#method-i-method_missing + def method_missing(name, *args) + if @available_tags.include? name.to_sym + instance_eval <<-RUBY + def #{name} + @data.send :#{name} + end + RUBY + send name.to_sym + else + super end - - rescue OptionParser::InvalidOption, OptionParser::MissingArgument => error - exit_with_message(error.to_s.capitalize) end - end - - def self.generate_xml - @tracks_processed = 0 - if @options.output.any? - puts "Generating XML..." - capture_stdout + + # Be a good person + # @api public + # @return [TrueClass] + # @see https://ruby-doc.org/core-2.5.0/Object.html#method-i-respond_to_missing-3F + def respond_to_missing?(name, *) + if @available_tags.include? name.to_sym + true + else + super + end + end + + + # Parse the options, mainly to find which ones were --no- + # @api private + # @return [] Doesn't matter, it's held in the @noes instance var. + def parse options + @noes = options.each_with_object([]){|(k,v), obj| + if k =~ /^\-\-no\-/ and v + taglib_name = k.match(/^\-\-no\-(?\w+)$/)[:name] + obj << TAGLIB_TO_XSPF[taglib_name.to_sym] + end + obj + } end - - puts @xml_tags[:header] - @options.dirs.each do |dir| - catch :MaxTracksReached do - begin - Find.find(dir) do |path| - xml_for_path(path) + + + # The file path + # @return [Pathname] + # @api public + attr_reader :path + + + # The available tags after the options have been parsed + # @return [] + # @api public + attr_reader :available_tags + + + # A data object that holds the taglib data in XSPF format + # @return [Struct] + # @api semipublic + attr_reader :data + + + # Process the options into a track entity. + # Calls TagLib + # @api private + def process refresh=false + if refresh or @processed == false + TagLib::FileRef.open(@path.to_path) do |fileref| + tags = fileref.tag + + unless tags.nil? # skip files with no tags + XSPF_TO_TAGLIB.select{|k,v| available_tags.include? k } + .each do |xspf,tagl| + @data.send "#{xspf}=", tags.respond_to?(:strip) ? tags.send(tagl).strip : tags.send(tagl) + end end - rescue Interrupt - abort("\nCancelled, exiting..") end + + @processed = true end end - puts @xml_tags[:footer] - - $stdout = STDOUT if @options.output.any? - end - def self.xml_for_path(path) - TagLib::FileRef.open(path) do |fileref| - tags = fileref.tag - - next if tags.nil? # skip files with no tags - - puts "#{@xml_tags[:track_start]}" - parse_location(path) - parse_tag(tags.title, @options.hide_title, @xml_tags[:title_start], @xml_tags[:title_end]) - parse_tag(tags.artist, @options.hide_artist, @xml_tags[:artist_start], @xml_tags[:artist_end]) - parse_tag(tags.album, @options.hide_album, @xml_tags[:album_start], @xml_tags[:album_end]) - parse_track_num(tags.track) - puts "#{@xml_tags[:track_end]}" - - @tracks_processed += 1 - throw :MaxTracksReached if @options.tracks_to_process[0].to_i > 0 and @tracks_processed == @options.tracks_to_process[0].to_i + + # For a block {|name,tag| ... } + # @yield [name,tag] Yield the element name and its contents. + # @api public + # @example + # # In ERB + # <% each_tag do |name,tag| %><%= "<#{name}>#{tag}\n" %> + def each_tag skip_nils=true + return enum_for(:each_tag) unless block_given? + @available_tags.each do |tag| + contents = self.send(tag) + if skip_nils and ( + contents.nil? or + (contents.respond_to?(:empty?) and contents.empty?) + ) + next + end + yield tag, contents + end end - end - - def self.parse_location(path) - if !@options.hide_location - encoded_path = URI.escape(path).sub("%5C", "/") # percent encode string for local path - puts "#{@xml_tags[:location_start]}#{encoded_path}#{@xml_tags[:location_end]}" + + + # The renderer + # @return [String] + # @api public + def to_xml + process + @template.render(self) end end - - def self.parse_tag(tag, suppress_output, start_xml, end_xml) - if !tag.nil? and !suppress_output - puts "#{start_xml}#{tag}#{end_xml}" + + + # Produces the playlist entity + # @see http://xspf.org/xspf-v1.html#rfc.section.4.1.1 + # @api public + class Playlist + + # @api public + # @example + # playlist = Spfy::Playlist.new options + def initialize options + set_traps_for_signals + @options = options.dup + @files = [] + @template = Tilt::ERBTemplate.new(TEMPLATES.join("playlist.xml.erb")) + parse @options end - end - - def self.parse_track_num(track_num) - if !@options.hide_tracknum and !track_num.nil? - if track_num > 0 - puts "#{@xml_tags[:track_num_start]}#{track_num}#{@xml_tags[:track_num_end]}" + + + # @see http://xspf.org/xspf-v1.html#rfc.section.4.1.1.2.2 + # @return [String] + attr_reader :creator + + # @see http://xspf.org/xspf-v1.html#rfc.section.4.1.1.2.2 + # @return [String] + attr_reader :title + + + # @return [Hash] The options hash passed in. + attr_reader :options + + # The paths that will be searched for files. + # @return [] + attr_reader :paths + + + # @see http://xspf.org/xspf-v1.html#rfc.section.4.1.1.2.3 + # @api public + # @return [String] + def annotation + @annotation + end + + + # @see http://xspf.org/xspf-v1.html#rfc.section.4.1.1.2.8 + # @api public + # @return [String] Time now formatted as an ISO8601 date. + def date + Time.now.iso8601 + end + + + # Parse the options further to set needed instance variables. + # spfy here there and everywhere + # { + # "--help" => false, + # "--output" => nil, + # "--title" => nil, + # "--creator" => nil, + # "--date" => nil, + # "--annotation" => nil, + # "--no-location" => false, + # "--no-title" => false, + # "--no-artist" => false, + # "--no-album" => false, + # "--no-tracknum" => false, + # "--max-tracks" => nil, + # "PATHS"=>["spec/support/fixtures/albums/mp4/mp4.m4a"], + # "--version" => false + # } + # @api private + # @return [Hash] Ignore the return value, this is a side-effect, it sets several instance variables + def parse options + @paths = (options.delete("PATHS") || []).map{|path| Pathname(path.sub /^~/, ENV["HOME"]) } + return if @paths.empty? + @title = if options["--title"] + options.delete("--title") + else + if @paths.first.directory? + @paths.first.basename + else + @paths.first.parent.basename + end end + @creator = options.delete("--creator") || ENV["USER"] + @annotation = options.delete("--annotation") || "Created with Spfy.rb" + @max_tracks = options["--max-tracks"] && options["--max-tracks"].to_i + options end - end - def self.exit_with_message(message) - puts message if message - exit_with_banner - end - - def self.exit_with_banner - puts USAGE - exit - end - - def self.capture_stdout - $stdout = File.open(@options.output[0], "w") + + # For interruptions + # @api private + # @return There is no return value, it's a side-effect + def set_traps_for_signals + trap(:SIGINT) do + warn " Received Ctrl+c" + # cleanup + exit 0 + end + end + + + # Render the playlist and any tracks. + # @api public + # @example + # playlist.to_xml + # @return [String] XML output + def to_xml + return "" if @paths.empty? + mapped = [] + catch(:MaxTracksReached){ + @paths.each do |path| + if !path.exist? + warn "#{path.to_path} does not exist" + next + end + if path.directory? + path.find do |pn| + if pn.directory? + pn.basename.to_s[0] == '.' ? + Find.prune : + next + else + mapped << Spfy::Track.new(pn, options: @options) + throw :MaxTracksReached if @max_tracks and mapped.size >= @max_tracks + end + end + else + mapped << Spfy::Track.new(path, options: @options) + throw :MaxTracksReached if @max_tracks and mapped.size >= @max_tracks + end + end + } + if mapped.size.zero? + fail RuntimeError, "No tracks found in #{@paths.map(&:to_path).join(' or ')}" + end + @template.render(self) do + mapped.map(&:to_xml).join("\n") + end + + rescue Spfy::Error + raise + rescue => error + # Tag any exceptions coming through this library + error.extend(Spfy::Error) + raise + end end - private_class_method :xml_for_path, :parse_location, :parse_tag, :parse_track_num, :exit_with_message, :exit_with_banner, :capture_stdout -end +end \ No newline at end of file diff --git a/lib/spfy/optionreader.rb b/lib/spfy/optionreader.rb deleted file mode 100644 index 1566bef..0000000 --- a/lib/spfy/optionreader.rb +++ /dev/null @@ -1,113 +0,0 @@ -# -# optionreader.rb -# Spfy ("spiffy") -# -# Copyright (c) 2012, Marc Ransome -# -# This file is part of Spfy. -# -# Spfy 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 3 of the License, or -# (at your option) any later version. -# -# Spfy 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 Spfy. If not, see . -# - -class OptionReader - - def self.parse(args) - - options = OpenStruct.new - options.output = [] - options.verbose = false - options.hide_title = false - options.hide_artist = false - options.hide_album = false - options.hide_location = false - options.hide_tracknum = false - options.dirs = [] - options.tracks_to_process = [] - - opts = OptionParser.new do |opts| - opts.banner = "Usage: #{File.basename($0)} [options] dir1 ... dirN" - - opts.separator "" - - opts.separator "#{File.basename($0).capitalize} supports multiple directory paths and traverses each directory recursively." - opts.separator "Non-audio files are ignored and will not impact #{File.basename($0)}'s output." - - opts.separator "" - opts.separator "Output options:" - - opts.on("-o", "--output FILE", "File to output XSPF data to") do |out| - options.output << out - end - - opts.on("-f", "--no-location", "Suppress file location output") do - options.hide_location = true - end - - opts.on("-t", "--no-title", "Suppress track title in output") do - options.hide_title = true - end - - opts.on("-a", "--no-artist", "Suppress artist name in output") do - options.hide_artist = true - end - - opts.on("-l", "--no-album", "Suppress album name in output") do - options.hide_album = true - end - - opts.on("-n", "--no-tracknum", "Suppress track number in output") do - options.hide_tracknum = true - end - - opts.on("-m", "--max-tracks NUM", "Limit the output to NUM tracks") do |num| - options.tracks_to_process << num - options.track_limit = true - end - - opts.separator "" - opts.separator "Misc options:" - - opts.on("-v", "--version", "Display version information") do - puts "#{File.basename($0).capitalize} #{Spfy::VERSION} Copyright (c) 2012 Marc Ransome " - puts "This program comes with ABSOLUTELY NO WARRANTY, use it at your own risk." - puts "This is free software, and you are welcome to redistribute it under" - puts "certain conditions; see LICENSE.txt for details." - exit - end - - opts.on_tail("-h", "--help", "Show this screen") do - puts opts - exit - end - end - - # parse then remove the remaining arguments - opts.parse!(args) - - # test leftover input for valid paths - args.each do |dir| - - # add path to global dirs variable - if File.directory?(dir) - options.dirs << dir - end - - end - - # return the options array - options - - end # def self.parse(args) - -end # class OptionReader diff --git a/lib/spfy/version.rb b/lib/spfy/version.rb new file mode 100644 index 0000000..3b3c9b2 --- /dev/null +++ b/lib/spfy/version.rb @@ -0,0 +1,5 @@ +module Spfy + # Library version, follows semver + # @see https://semver.org + VERSION="1.0.0" +end \ No newline at end of file diff --git a/lib/templates/playlist.xml.erb b/lib/templates/playlist.xml.erb new file mode 100644 index 0000000..25ae2f0 --- /dev/null +++ b/lib/templates/playlist.xml.erb @@ -0,0 +1,10 @@ + + + <%= title.to_s.encode(:xml => :text) %> + <%= creator.to_s.encode(:xml => :text) %> + <%= date.to_s.encode(:xml => :text) %> + <%= annotation.to_s.encode(:xml => :text) %> + + <%= yield %> + + \ No newline at end of file diff --git a/lib/templates/track.xml.erb b/lib/templates/track.xml.erb new file mode 100644 index 0000000..db8c1c5 --- /dev/null +++ b/lib/templates/track.xml.erb @@ -0,0 +1,3 @@ + +<% each_tag do |name,tag| %><%= "<#{name}>#{tag.to_s.encode(:xml => :text)}\n" %> +<% end %> \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..8d6d9a2 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,32 @@ +require 'simplecov' +require "rspec/its" +require "rspec-given" +require 'timecop' + +SimpleCov.start do + add_filter "/spec/" + add_filter "/coverage/" + add_filter "/vendor.noindex/" +end + +require 'pathname' +Fixtures = Pathname(__dir__).join("support/fixtures") + +if ENV["DEBUG"] + require 'pry-byebug' + require 'pry-state' + binding.pry +end + + +RSpec.configure do |config| + config.expect_with(:rspec) { |c| c.syntax = [:should,:expect] } + + config.before(:each, :time_sensitive => true) do + Timecop.freeze Time.parse "2018-03-11T06:49:16+00:00" + end + + config.after(:each, :time_sensitive => true) do + Timecop.return + end +end \ No newline at end of file diff --git a/spec/spfy_spec.rb b/spec/spfy_spec.rb new file mode 100644 index 0000000..9be8899 --- /dev/null +++ b/spec/spfy_spec.rb @@ -0,0 +1,331 @@ +require 'spec_helper' +require_relative "../lib/spfy.rb" +require 'open3' + +Albums = Fixtures.join("albums") + +# Must use tidy because Haml and ERB cannot be trusted to produce +# the same data each time or between versions +def cmd str + stdout, _= Open3.capture2("tidy -q -i -xml", stdin_data: str) +# puts stdout + stdout +end + +describe "Track" do + + context "Given some noes" do + Given(:path) { Albums.join("mp4/mp4.m4a") } + context "No location" do + Given(:options) { + { + "--no-location" => true, + "--no-title" => false, + "--no-artist" => false, + "--no-album" => false, + "--no-tracknum" => false, + } + } + Given(:track) { Spfy::Track.new path, options: options } + context "the unit" do + Then { track.instance_variable_get(:@noes) == [:location] } + + Then { track.available_tags.sort == (Spfy::Track::XSPF_TAGS - [:location]).sort } + And { track.respond_to?(:location) == false } + And { track.respond_to? :album } + And { track.respond_to? :creator } + And { track.respond_to? :annotation } + And { track.respond_to? :title } + And { track.respond_to? :trackNum } + And { track.respond_to? :data } + + Then { track.data.respond_to?(:location) == false } + And { track.data.respond_to? :album } + And { track.data.respond_to? :creator } + And { track.data.respond_to? :annotation } + And { track.data.respond_to? :title } + And { track.data.respond_to? :trackNum } + + Then { track.album == "Album" } + And { track.creator == "Artist" } + And { track.annotation == "Comment" } + And { track.title == "Title" } + And { track.trackNum == 7 } + end + end + context "No title or artist" do + Given(:options) { + { + "--no-location" => false, + "--no-title" => true, + "--no-artist" => true, + "--no-album" => false, + "--no-tracknum" => false, + } + } + Given(:track) { Spfy::Track.new path, options: options } + context "the unit" do + Then { track.instance_variable_get(:@noes).sort == [:creator,:title].sort } + + Then { track.available_tags.sort == (Spfy::Track::XSPF_TAGS - [:creator,:title]).sort } + And { track.respond_to?(:location) } + And { track.respond_to? :album } + And { track.respond_to?( :creator ) == false } + And { track.respond_to? :annotation } + And { track.respond_to?( :title ) == false } + And { track.respond_to? :trackNum } + And { track.respond_to? :data } + + Then { track.data.respond_to? :location } + And { track.data.respond_to? :album } + And { track.data.respond_to?( :creator ) == false } + And { track.data.respond_to? :annotation } + And { track.data.respond_to?( :title ) == false } + And { track.data.respond_to? :trackNum } + + Then { track.album == "Album" } + And { track.annotation == "Comment" } + And { track.trackNum == 7 } + end + end + + end + + context "Most simple case" do + Given(:path) { Albums.join("mp4/mp4.m4a") } + Given(:track) { Spfy::Track.new path } + # Mung the path in the file so it works on any machine + Given(:location) { + track = Albums.join("mp4/mp4.m4a").to_path + Addressable::URI.join( "file:///", Addressable::URI.escape( track )) + } + Given(:xml_comparator) { + Fixtures.join("mp4.m4a.xml") + .read + .sub /FIXTURE/, location.to_s + } + context "the unit" do + Then { track.available_tags == Spfy::Track::XSPF_TAGS } + And { track.respond_to? :location } + And { track.respond_to? :album } + And { track.respond_to? :creator } + And { track.respond_to? :annotation } + And { track.respond_to? :title } + And { track.respond_to? :trackNum } + And { track.respond_to? :data } + + Then { track.data.respond_to? :location } + And { track.data.respond_to? :album } + And { track.data.respond_to? :creator } + And { track.data.respond_to? :annotation } + And { track.data.respond_to? :title } + And { track.data.respond_to? :trackNum } + + Then { track.location == location } + And { track.album == "Album" } + And { track.creator == "Artist" } + And { track.annotation == "Comment" } + And { track.title == "Title" } + And { track.trackNum == 7 } + end + + + context "render" do + When(:xml) { track.to_xml } + Then { cmd(xml.chomp) == cmd( xml_comparator.chomp ) } + end + end +end + +describe "Playlist" do + context "Most simple case", :time_sensitive do + Given(:options) { + { + "--title" => nil, + "--creator" => nil, + "--date" => nil, + "--annotation" => nil, + "--no-location" => false, + "--no-title" => false, + "--no-artist" => false, + "--no-album" => false, + "--no-tracknum" => false, + "--max-tracks" => nil, + "PATHS"=>["spec/support/fixtures/albums/mp4/mp4.m4a"], + } + } + Given(:playlist) { Spfy::Playlist.new( options ) } + Given(:xml_comparator) { + track = Albums.join("mp4/mp4.m4a").to_path + Fixtures.join("mp4.m4a.playlist.xml").read + .sub( /FIXTURE/, Addressable::URI.join( "file:///", Addressable::URI.escape( track )).to_s ) + .sub( /USER/, ENV["USER"] ) + } + When(:xml) { playlist.to_xml } + Then { cmd(xml.chomp) == cmd(xml_comparator.chomp) } +# Then { playlist.options == {} } +# Then { playlist.paths == [] } +# Then { playlist.creator == "ME" } +# Then { playlist.date == "today" } +# Then { playlist.annotation == "notes" } + end + + context "A directory of files", :time_sensitive do + Given(:options) { + { + "--title" => nil, + "--creator" => nil, + "--date" => nil, + "--annotation" => nil, + "--no-location" => false, + "--no-title" => false, + "--no-artist" => false, + "--no-album" => false, + "--no-tracknum" => false, + "--max-tracks" => nil, + "PATHS"=>["spec/support/fixtures/albums/mp3"], + } + } + Given(:playlist) { Spfy::Playlist.new( options ) } + Given(:xml_comparator) { + Fixtures.join("mp3.playlist.xml").read + .gsub( /ALBUMS/, Addressable::URI.join( "file:///", Addressable::URI.escape( Albums.to_path)).to_s ) + .sub( /USER/, ENV["USER"] ) + } + When(:xml) { playlist.to_xml } + Then { cmd(xml.chomp) == cmd(xml_comparator.chomp) } + end + + context "A directory of directories of files", :time_sensitive do + Given(:options) { + { + "--title" => nil, + "--creator" => nil, + "--date" => nil, + "--annotation" => nil, + "--no-location" => false, + "--no-title" => false, + "--no-artist" => false, + "--no-album" => false, + "--no-tracknum" => false, + "--max-tracks" => nil, + "PATHS"=>["spec/support/fixtures/albums"], + } + } + Given(:playlist) { Spfy::Playlist.new( options ) } + Given(:xml_comparator) { + Fixtures.join("all-albums.playlist.xml").read + .gsub( /ALBUMS/, Addressable::URI.join( "file:///", Addressable::URI.escape( Albums.to_path)).to_s ) + .sub( /USER/, ENV["USER"] ) + } + When(:xml) { playlist.to_xml } + Then { cmd(xml.chomp) == cmd(xml_comparator.chomp) } + end + + context "Multiple locations", :time_sensitive do + Given(:options) { + { + "--title" => nil, + "--creator" => nil, + "--date" => nil, + "--annotation" => nil, + "--no-location" => false, + "--no-title" => false, + "--no-artist" => false, + "--no-album" => false, + "--no-tracknum" => false, + "--max-tracks" => nil, + "PATHS"=>["spec/support/fixtures/albums/mp3", "spec/support/fixtures/albums/mp4"], + } + } + Given(:playlist) { Spfy::Playlist.new( options ) } + Given(:xml_comparator) { + Fixtures.join("mp3-and-mp4.playlist.xml").read + .gsub( /ALBUMS/, Addressable::URI.join( "file:///", Addressable::URI.escape( Albums.to_path)).to_s ) + .sub( /USER/, ENV["USER"] ) + } + When(:xml) { playlist.to_xml } + Then { cmd(xml.chomp) == cmd(xml_comparator.chomp) } + end + + context "Given a max number of tracks" do + context "When the no. of tracks found is greater" do + Given(:options) { + { + "--title" => nil, + "--creator" => nil, + "--date" => nil, + "--annotation" => nil, + "--no-location" => false, + "--no-title" => false, + "--no-artist" => false, + "--no-album" => false, + "--no-tracknum" => false, + "--max-tracks" => 10, + "PATHS"=>["spec/support/fixtures/albums"], + } + } + Given(:playlist) { Spfy::Playlist.new( options ) } + When(:xml) { playlist.to_xml } + Then { + options["--max-tracks"].to_i == + cmd(xml.chomp).split("\n") + .select{|line| + line =~ /\/ + }.size + } + end + context "When the no. of tracks found is less", :time_sensitive do + Given(:options) { + { + "--title" => nil, + "--creator" => nil, + "--date" => nil, + "--annotation" => nil, + "--no-location" => false, + "--no-title" => false, + "--no-artist" => false, + "--no-album" => false, + "--no-tracknum" => false, + "--max-tracks" => 10, + "PATHS"=>["spec/support/fixtures/albums/mp3"], + } + } + Given(:playlist) { Spfy::Playlist.new( options ) } + Given(:xml_comparator) { + Fixtures.join("mp3.playlist.xml").read + .gsub( /ALBUMS/, Addressable::URI.join( "file:///", Addressable::URI.escape( Albums.to_path)).to_s ) + .sub( /USER/, ENV["USER"] ) + } + When(:xml) { playlist.to_xml } + Then { cmd(xml.chomp) == cmd(xml_comparator.chomp) } + Then { + options["--max-tracks"].to_i > + cmd(xml.chomp).split("\n") + .select{|line| + line =~ /\/ + }.size + } + end + end + + context "Given paths with no media files" do + Given(:options) { + { + "--title" => nil, + "--creator" => nil, + "--date" => nil, + "--annotation" => nil, + "--no-location" => false, + "--no-title" => false, + "--no-artist" => false, + "--no-album" => false, + "--no-tracknum" => false, + "--max-tracks" => 10, + "PATHS"=>["spec/support/fixtures/albums/empty"], + } + } + Given(:playlist) { Spfy::Playlist.new( options ) } + Then { expect { playlist.to_xml }.to raise_error(Spfy::Error) } + end +end \ No newline at end of file diff --git a/spec/support/fixtures/all-albums.playlist.xml b/spec/support/fixtures/all-albums.playlist.xml new file mode 100644 index 0000000..6630566 --- /dev/null +++ b/spec/support/fixtures/all-albums.playlist.xml @@ -0,0 +1,78 @@ + + + albums + USER + 2018-03-11T06:49:16+00:00 + Created with Spfy.rb + + + ALBUMS/flac/flac.flac + Album + Artist + Test file + Title + 7 + + + ALBUMS/mp3/crash.mp3 + 0 + + + ALBUMS/mp3/id3v1.mp3 + Album + Artist + Comment + Title + 7 + + + ALBUMS/mp3/relative-volume.mp3 + 0 + + + ALBUMS/mp3/sample.mp3 + Dummy Album + Dummy Artist + Dummy Comment + Dummy Title + 1 + + + ALBUMS/mp3/unicode.mp3 + 你好 + 0 + + + ALBUMS/mp4/aiff-sample.aiff + AIFF Dummy Album Title + AIFF Dummy Artist Name + AIFF Dummy Comment + AIFF Dummy Track Title - ID3v2.4 + 3 + + + ALBUMS/mp4/mp4.m4a + Album + Artist + Comment + Title + 7 + + + ALBUMS/oga/vorbis.oga + Album + Artist + Test file + Title + 7 + + + ALBUMS/wav/wav-sample.wav + WAV Dummy Album Title + WAV Dummy Artist Name + WAV Dummy Comment + WAV Dummy Track Title + 5 + + + \ No newline at end of file diff --git a/spec/support/fixtures/mp3-and-mp4.playlist.xml b/spec/support/fixtures/mp3-and-mp4.playlist.xml new file mode 100644 index 0000000..470a61c --- /dev/null +++ b/spec/support/fixtures/mp3-and-mp4.playlist.xml @@ -0,0 +1,54 @@ + + + mp3 + USER + 2018-03-11T06:49:16+00:00 + Created with Spfy.rb + + + ALBUMS/mp3/crash.mp3 + 0 + + + ALBUMS/mp3/id3v1.mp3 + Album + Artist + Comment + Title + 7 + + + ALBUMS/mp3/relative-volume.mp3 + 0 + + + ALBUMS/mp3/sample.mp3 + Dummy Album + Dummy Artist + Dummy Comment + Dummy Title + 1 + + + ALBUMS/mp3/unicode.mp3 + 你好 + 0 + + + ALBUMS/mp4/aiff-sample.aiff + AIFF Dummy Album Title + AIFF Dummy Artist Name + AIFF Dummy Comment + AIFF Dummy Track Title - ID3v2.4 + 3 + + + ALBUMS/mp4/mp4.m4a + Album + Artist + Comment + Title + 7 + + + \ No newline at end of file diff --git a/spec/support/fixtures/mp3.playlist.xml b/spec/support/fixtures/mp3.playlist.xml new file mode 100644 index 0000000..6a60643 --- /dev/null +++ b/spec/support/fixtures/mp3.playlist.xml @@ -0,0 +1,38 @@ + + + mp3 + USER + 2018-03-11T06:49:16+00:00 + Created with Spfy.rb + + + ALBUMS/mp3/crash.mp3 + 0 + + + ALBUMS/mp3/id3v1.mp3 + Album + Artist + Comment + Title + 7 + + + ALBUMS/mp3/relative-volume.mp3 + 0 + + + ALBUMS/mp3/sample.mp3 + Dummy Album + Dummy Artist + Dummy Comment + Dummy Title + 1 + + + ALBUMS/mp3/unicode.mp3 + 你好 + 0 + + + \ No newline at end of file diff --git a/spec/support/fixtures/mp4.m4a.playlist.xml b/spec/support/fixtures/mp4.m4a.playlist.xml new file mode 100644 index 0000000..1a3b392 --- /dev/null +++ b/spec/support/fixtures/mp4.m4a.playlist.xml @@ -0,0 +1,17 @@ + + + mp4 + USER + 2018-03-11T06:49:16+00:00 + Created with Spfy.rb + + + FIXTURE + Album + Artist + Comment + Title + 7 + + + \ No newline at end of file diff --git a/spec/support/fixtures/mp4.m4a.xml b/spec/support/fixtures/mp4.m4a.xml new file mode 100644 index 0000000..d35baa4 --- /dev/null +++ b/spec/support/fixtures/mp4.m4a.xml @@ -0,0 +1,8 @@ + + FIXTURE + Album + Artist + Comment + Title + 7 + \ No newline at end of file diff --git a/spfy.gemspec b/spfy.gemspec index a2816cb..4ec2a19 100644 --- a/spfy.gemspec +++ b/spfy.gemspec @@ -1,36 +1,21 @@ -# -# spfy.gemspec -# Spfy ("spiffy") -# -# Copyright (c) 2012, Marc Ransome -# -# This file is part of Spfy. -# -# Spfy 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 3 of the License, or -# (at your option) any later version. -# -# Spfy 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 Spfy. If not, see . -# +# -*- encoding: utf-8 -*- +require File.expand_path('../lib/spfy/version', __FILE__) -Gem::Specification.new do |s| - s.name = 'spfy' - s.version = '1.0.0' - s.date = '2013-07-28' - s.summary = 'XSPF playlist generator' - s.description = 'Spfy is a simple command-line tool for generating XSPF playlists from metadata stored in several popular audio formats.' - s.authors = ["Marc Ransome"] - s.email = 'marc.ransome@fidgetbox.co.uk' - s.files = `git ls-files`.split("\n") - s.executables << 'spfy' - s.add_runtime_dependency 'taglib-ruby', '>= 0.5.0' - s.homepage = 'http://marcransome.github.com/spfy' - s.license = 'GPL-3' +Gem::Specification.new do |gem| + gem.name = "spfy" + gem.version = Spfy::VERSION + gem.summary = 'XSPF playlist generator' + gem.description = 'Spfy is a simple command-line tool for generating XSPF playlists from metadata stored in several popular audio formats.' + gem.authors = ["Marc Ransome"] + gem.email = 'marc.ransome@fidgetbox.co.uk' + gem.files = `git ls-files`.split("\n") + gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) + gem.bindir = "exe" + gem.executables = gem.files.grep(%r{^exe/}).map{ |f| File.basename(f) } + gem.add_runtime_dependency 'taglib-ruby', '>= 0.5.0' + gem.add_runtime_dependency "docopt", ">= 0.6.1" + gem.add_runtime_dependency "tilt", ">=2.0.8" + gem.add_runtime_dependency "addressable", ">=2.5.2" + gem.homepage = 'https://github.com/marcransome/Spfy' + gem.license = 'GPL-3.0' end