Apple TV for Music
I wanted to play my music through my Apple TV. Apple have a service for that they think I should use instead.
DAAP
iTunes music sharing uses the Digital Audio Access Protocol (DAAP). To summarise, DAAP is DLNA for Apple. I don’t really like either. A DAAP server is really just a HTTP server that returns XML. iTunes discovers local (exclusively local) DAAP servers using zeroconf. My hope was that my Apple TV would be able to play my music library from my NAS, if my NAS could run a DAAP server.
ALAC and FLAC
In my case, all my music is FLAC. So it needs transcoding. To do that, I rigged a quick-and-dirty script in ruby:
※ 2025-09: This script was reworked to be less fussy about paths
#!/usr/bin/env ruby
require 'shellwords'
require 'fileutils'
# Script options, called below.
# fmt refers to audio codec, container refers to file suffix.
# i.e. opus stored in ogg, aac/alac stored in m4a.
Opts = Struct.new(:workers,
:src_dir, :dst_dir,
:src_fmt, :src_container,
:dst_fmt, :dst_container)
# An album, within a genre, containing tracks. Initialisation triggers
# population of the conversion_queue.
# Shellwords.escape is used when processing any pathstring that
# ends up in a command, as track titles are unpredictable.
class Album
attr_accessor :genre, :in_files, :out_files, :src_art, :dst_art
def initialize(opts, genre, album, conversion_queue)
@opts = opts
@genre = genre
@album = album
@path = "#{@opts.src_dir}/#{@genre}/#{@album}"
@in_files = gather_tracks(@path)
@out_files = @in_files.map { |f| resolve_track_destination(f) }
@src_art = "#{@opts.src_dir}/#{genre}/#{album}/folder.jpg"
@dst_art = "#{@opts.dst_dir}/#{genre}/#{album}/folder.jpg"
enqueue!(conversion_queue)
end
# Resolve an album to its children.
# Notably, we do not escape the path at this point, as we use the path
# when we resolve the destination of the transcode.
def gather_tracks(album_path)
tracks = Dir.open(album_path).children.select { |f| f.end_with? @opts.src_container }
tracks.map { |t| "#{album_path}/#{t}" }
end
# Resolve a track to its destination post-conversion
# ALAC files are stored in M4A containers, so correct the destination
# suffix while we're at it.
def resolve_track_destination(file_path)
file_path = file_path.gsub(@opts.src_dir, @opts.dst_dir)
file_path.gsub!(@opts.src_container, @opts.dst_container)
Shellwords.escape(file_path)
end
def copy_art!(resize)
puts 'TODO: resize with magick' if resize
FileUtils.copy_file(@src_art, @dst_art) unless File.exist?(@dst_art)
end
# Add to the provided conversion queue a series of shell commands to execute.
# We handle art ahead of time.
def enqueue!(queue)
FileUtils.mkdir_p("#{@opts.dst_dir}/#{@genre}/#{@album}")
copy_art!(false)
@out_files.reject! { |f| File.exist?(f) }
@out_files.each_with_index do |_, i|
queue << "ffmpeg -i #{Shellwords.escape(@in_files[i])} -acodec #{@opts.dst_fmt} -map a #{@out_files[i]}"
end
end
end
# A music library, structured <group>/<genre>/<album>/<tracks>
class Library
attr_accessor :conversion_queue, :genres, :albums, :active_workers
def initialize(opts)
@opts = opts
puts "Converting files in #{@opts.src_dir} from #{@opts.src_fmt} to #{@opts.dst_fmt} (.#{@opts.dst_container})"
@conversion_queue = Queue.new
@genres = Dir.open(@opts.src_dir).children
@albums = @genres.map do |genre|
puts "Enumerating #{genre}"
gather_albums(genre)
puts "Finished with #{genre} -- #{@conversion_queue.length} tracks in conversion queue"
end
@active_workers = []
end
# Resolve a genre to its constituent Album objects.
def gather_albums(genre)
genre_albums = Dir.open("#{@opts.src_dir}/#{genre}").children
genre_albums.reject! { |a| a.start_with? '.' }
genre_albums.map { |album| Album.new(@opts, genre, album, @conversion_queue) }
end
# Spawn workers to consume and execute commands from the
# conversion queue. Wait until all workers have returned.
def convert_parallel!
@opts.workers.times { |_| spawn_worker! }
@active_workers.each(&:join)
end
def convert_one!
spawn_worker!
@active_workers.each(&:join)
end
# Start a thread (worker) to pop from the queue until empty.
def spawn_worker!
@active_workers << Thread.new do
until @conversion_queue.empty?
command = @conversion_queue.pop(true)
puts command unless command.nil?
end
end
end
end
options = Opts.new(workers: 10,
src_dir: '/mnt/XS1/music/flac',
dst_dir: '/mnt/XS1/music/alac',
src_fmt: 'flac',
src_container: 'flac',
dst_fmt: 'alac',
dst_container: 'm4a')
library = Library.new(options)
library.convert_parallel!
Owntone
So, with my now-Apple-certified library, I needed a DAAP server. Owntone is such a DAAP server. Highly portable (it’s just C), and a doddle to set up. In my case I built it from source with the web interface disabled:
./configure --disable-spotify --disable-webinterface --without-libwebsockets
After which, I configured it to look at my new ALAC library:
directories = { "/mnt/H2C/music/alac" }
Disappointment
Of course, it doesn’t work. Owntone works. The files are fine. I can play from Owntone on a Mac without issue. But the geniuses at Apple decided that if you want to stream music from an iTunes Library (well, yes, that’s what Owntone emulates) to tvOS - you should buy an Apple Music subscription.
Oh do sod off.
I suppose I’ll end up writing an article about knocking up a Jellyfin client for tvOS at some point. I don’t know swift, nor have any particular desire to know it. But I really do just want a basic genre/album/track selection. With a bit of album art taken from folder.jpg. Jellyfin is already running on my network, so I might as well use that API. We’ll see.