Class: Lllibrary

Inherits:
Object
  • Object
show all
Defined in:
lib/lllibrary.rb,
lib/lllibrary/dsl.rb,
lib/lllibrary/track.rb,
lib/lllibrary/database.rb,
lib/lllibrary/playlist.rb,
lib/lllibrary/playlist_item.rb

Overview

A Lllibrary represents a database of tracks and playlists. It helps you manage and query this database by automatically pulling metadata from the audio files you add, being able to import music libraries from other programs like iTunes, and providing a DSL for selecting songs from your music library with ease.

Defined Under Namespace

Classes: DSL, Database, Playlist, PlaylistItem, Track

Constant Summary collapse

TAGLIB_METADATA =

These are the fields that taglib can pull from audio files. The keys are taglib’s names for them, the values are the column names that lllibrary uses by default.

{
  tag: {
    album: "album",
    artist: "artist",
    comment: "comments",
    genre: "genre",
    title: "title",
    track: "track_number",
    year: "year"
  },
  audio_properties: {
    length: "total_time",
    bitrate: "bit_rate",
    channels: "channels",
    sample_rate: "sample_rate"
  }
}

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(db_path, db_adapter, &schema_blk) ⇒ Lllibrary

Create a new Lllibrary object. Takes the path to the database and the database adapter (e.g. “sqlite3”). It also takes a block which specifies the schema of the database. Without this block, the tracks table will only have three fields: location, created_at, and updated_at. You need to provide a block if you want your tracks table to have fields for metadata. Here’s an example:

library = Lllibrary.new("songs.sqlite3", "sqlite3") do |t|
  t.string :title
  t.string :artist
  t.string :album
  t.integer :year
end

There are also a couple aliases that automatically add a bunch of metadata fields for you. These are t.default_metadata and t.itunes_metadata.

t.default_metadata adds these fields: album, artist, comments, genre, title, track_number, year, total_time, bit_rate, channels, sample_rate. These are the fields that taglib is able to fill in by examining the audio files you add.

t.itunes_metadata add almost all of the metadata fields that iTunes uses, and will be filled in when you import your iTunes library. These fields are: original_id, title, artist, composer, album, album_artist, genre, total_time, disc_number, disc_count, track_number, track_count, year, date_modified, date_added, bit_rate, sample_rate, comments, play_count, play_date, skip_count, skip_date, rating.

Note: ActiveRecord doesn’t seem to allow you to connect to multiple databases at the same time. Please only instantiate one Lllibrary per process.



72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/lllibrary.rb', line 72

def initialize(db_path, db_adapter, &schema_blk)
  @db = Lllibrary::Database.new(db_path, db_adapter)
  if schema_blk
    @db.generate_schema do |t|
      t.string :location
      t.timestamps

      schema_blk.call(t)
    end
  end
  @dsl = Lllibrary::DSL.new(self)
end

Instance Attribute Details

#dslObject (readonly)

Returns the value of attribute dsl.



17
18
19
# File 'lib/lllibrary.rb', line 17

def dsl
  @dsl
end

Class Method Details

.format_time(milliseconds, options = {}) ⇒ Object

Helper method to format a number of milliseconds as a string like “1:03:56.555”. The only option is :include_milliseconds, true by default. If false, milliseconds won’t be included in the formatted string.



261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/lllibrary.rb', line 261

def self.format_time(milliseconds, options = {})
  ms = milliseconds % 1000
  seconds = (milliseconds / 1000) % 60
  minutes = (milliseconds / 60000) % 60
  hours = milliseconds / 3600000

  if ms.zero? || options[:include_milliseconds] == false
    ms_string = ""
  else
    ms_string = ".%03d" % [ms]
  end

  if hours > 0
    "%d:%02d:%02d%s" % [hours, minutes, seconds, ms_string]
  else
    "%d:%02d%s" % [minutes, seconds, ms_string]
  end
end

.parse_time(string) ⇒ Object

Helper method to parse a string like “1:03:56.555” and return the number of milliseconds that time length represents.



282
283
284
285
286
287
288
289
# File 'lib/lllibrary.rb', line 282

def self.parse_time(string)
  parts = string.split(":").map(&:to_f)
  parts = [0] + parts if parts.length == 2
  hours, minutes, seconds = parts
  seconds = hours * 3600 + minutes * 60 + seconds
  milliseconds = seconds * 1000
  milliseconds.to_i
end

Instance Method Details

#add(paths_to_tracks, &blk) ⇒ Object

Adds one or more tracks to the library. Takes a path to the audio file you wanted added, or array of multiple paths. Uses taglib to fill in metadata for each track, if the corresponding database fields exist. After filling in the metadata, if a block was given, it yields the Track object to this block. If the block returns a Track object (with possible modifications of your own), it then saves the Track and goes on to the next one. If the block doesn’t return a Track, the track is not saved.

For example, here’s how you would use the filename as the title of the track if the title is missing from the audio file’s metadata:

library.add(Dir["Music/**/*.mp3"]) do |track|
  track.title ||= File.basename(track.location, ".mp3")
  track
end


132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/lllibrary.rb', line 132

def add(paths_to_tracks, &blk)
  Array(paths_to_tracks).each do |path|
    track = tracks.new(location: File.expand_path(path))

    TagLib::FileRef.open(path) do |audio_file|
      TAGLIB_METADATA.each do |tag_or_audio_properties, properties|
        if audio_file.send(tag_or_audio_properties)
          properties.each do |property, column|
            value = audio_file.send(tag_or_audio_properties).send(property)
            value *= 1000 if property == :length # convert seconds to milliseconds
            value = nil if value == 0
            track.send("#{column}=", value) if Track.column_names.include? column
          end
        end
      end
    end

    track = blk.call(track) if blk
    track.save! if track.is_a?(Track)
  end
end

#add_playlist(name, tracks) ⇒ Object

Creates a new playlist and saves it. Takes a name for the playlist and an Array of Tracks. The order of the Tracks is preserved, of course.



156
157
158
159
160
161
162
163
164
165
166
# File 'lib/lllibrary.rb', line 156

def add_playlist(name, tracks)
  playlist = playlists.new(name: name)
  playlist.save!
  tracks.each.with_index do |track, i|
    playlist_item = Lllibrary::PlaylistItem.new
    playlist_item.playlist = playlist
    playlist_item.track = track
    playlist_item.position = i
    playlist_item.save!
  end
end

#clear_allObject

Deletes all tracks and playlists.



253
254
255
256
# File 'lib/lllibrary.rb', line 253

def clear_all
  tracks.destroy_all
  clear_playlists
end

#clear_playlistsObject

Deletes all your playlists.



248
249
250
# File 'lib/lllibrary.rb', line 248

def clear_playlists
  playlists.destroy_all
end

#import(type, path, options = {}, &blk) ⇒ Object

Imports a music library from another program, such as iTunes. Incidentally, iTunes is the only such program supported right now. Here’s an example:

library.import :itunes, "path/to/iTunes Music Library.xml", logger: method(:puts)

I will probably redesign this method to be more flexible and such, so I’ll curb my documenting of it any further till then.



175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/lllibrary.rb', line 175

def import(type, path, options = {}, &blk)
  logger = options[:logger]
  if type == :itunes
    logger.("Parsing XML...") if logger
    data = Plist::parse_xml(path)

    logger.("Importing #{data['Tracks'].length} tracks...") if logger
    num_tracks = 0
    whitelist = tracks.new.attributes.keys
    data["Tracks"].each do |track_id, row|
      if row["Kind"] !~ /audio/
        logger.("[skipping non-audio file]") if logger
        next
      end

      # row already contains a hash of attributes almost ready to be passed to
      # ActiveRecord. We just need to modify the keys, e.g. change "Play Count"
      # to "play_count".
      row["Title"] = row.delete("Name")
      row["Play Date"] = row.delete("Play Date UTC")
      row["Original ID"] = row.delete("Track ID")
      attributes = row.inject({}) do |acc, (key, value)|
        attribute = key.gsub(" ", "").underscore
        acc[attribute] = value if whitelist.include? attribute
        acc
      end

      # change iTunes' URL-style locations into simple paths
      if attributes["location"] && attributes["location"] =~ /^file:\/\//
        attributes["location"].sub! /^file:\/\/localhost/, ""

        # CGI::unescape changes plus signs to spaces. This is a work around to
        # keep the plus signs.
        attributes["location"].gsub! "+", "%2B"

        attributes["location"] = CGI::unescape(attributes["location"])
      end

      track = tracks.new(attributes)
      track = blk.call(track)
      if track && track.save
        num_tracks += 1
      end
    end
    logger.("Imported #{num_tracks} tracks successfully.") if logger

    if tracks.new.attributes.keys.include? "original_id"
      logger.("Importing #{data['Playlists'].length} playlists...") if logger
      num_playlists = 0
      data["Playlists"].each do |playlist_data|
        playlist = []

        if ["Library", "Music", "Movies", "TV Shows", "iTunes DJ"].include? playlist_data["Name"]
          logger.("[skipping \"#{playlist_data['Name']}\" playlist]") if logger
        elsif playlist_data["Playlist Items"].nil?
          logger.("[skipping \"#{playlist_data['Name']}\" playlist (because it's empty)]") if logger
        else
          playlist_data["Playlist Items"].map(&:values).flatten.each do |original_id|
            playlist << tracks.where(original_id: original_id).first
          end
          playlist.compact!
          add_playlist(playlist_data["Name"], playlist)
          num_playlists += 1
        end
      end
      logger.("Imported #{num_playlists} playlists successfully.") if logger
    else
      logger.("Can't import playlists because tracks table doesn't have an original_id field.") if logger
    end
  end
end

#playlistsObject

Returns a bare Relation of the Playlist model.



112
113
114
# File 'lib/lllibrary.rb', line 112

def playlists
  Lllibrary::Playlist.scoped
end

#select(&blk) ⇒ Object

Takes a block, and evaluates that block in the context of the Lllibrary’s DSL. Inside the block, every database field on the tracks table becomes a method called a selector. Each selector takes a value to match tracks against, and returns an Array of those tracks. String-like fields have string selectors, and number-like fields have numeric selectors. There is also an all selector, a none selector, and a playlist selector. See dsl.rb for a detailed description of these.

library.select do
  composer(:rachmanino)    # example of string selector
  year(2010..2012)         # example of numeric selector
  total_time(gt: "6:00")   # example of time selector
  playlist(:energetic)     # example of playlist selector
  all                      # returns array of all tracks
  none                     # returns empty array
end


102
103
104
# File 'lib/lllibrary.rb', line 102

def select(&blk)
  @dsl.instance_eval &blk
end

#tracksObject

Returns a bare Relation of the Track model.



107
108
109
# File 'lib/lllibrary.rb', line 107

def tracks
  Lllibrary::Track.scoped
end