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
```
-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_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}#{name}>\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 @@
+
\ 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 =~ /\