Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cue sheet support #457

Open
wants to merge 9 commits into
base: devel
Choose a base branch
from
35 changes: 27 additions & 8 deletions audiotranscode/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
'm4a' : 'audio/m4a',
'wav' : 'audio/wav',
'wma' : 'audio/x-ms-wma',
'cue': '', # FIXME: What should we put here? .cue doesn't actually contain any audio.
}

class Transcoder(object):
Expand Down Expand Up @@ -52,13 +53,30 @@ def __init__(self, filetype, command):
self.filetype = filetype
self.mimetype = MimeTypes[filetype]

def decode(self, filepath, starttime=0):
def decode(self, filepath, starttime=0, duration=None):
# Fetch audio filepath from cue sheets
ext = os.path.splitext(filepath)[1]
if ext == '.cue':
from cherrymusicserver.cuesheet import Cuesheet
cue = Cuesheet(filepath)
for cdtext in cue.info[0].cdtext:
if cdtext.type == 'FILE':
filepath = os.path.join(os.path.dirname(filepath), cdtext.value[0])
break
cmd = self.command[:]
if 'INPUT' in cmd:
cmd[cmd.index('INPUT')] = filepath
if 'STARTTIME' in cmd:
hours, minutes, seconds = starttime//3600, starttime//60%60, starttime%60
cmd[cmd.index('STARTTIME')] = '%d:%d:%d' % (hours, minutes, seconds)
# Seconds should include decimals so that multi-track files don't
# accidentally include the last second of the previous track.
cmd[cmd.index('STARTTIME')] = '%d:%d:%f' % (hours, minutes, seconds)
if 'DURATION' in cmd:
# FIXME: We should remove the duration flag instead of working around it like this.
if duration is None:
duration = 100000
hours, minutes, seconds = duration//3600, duration//60%60, duration%60
cmd[cmd.index('DURATION')] = '%d:%d:%f' % (hours, minutes, seconds)
if 'INPUT' in cmd:
cmd[cmd.index('INPUT')] = filepath
return subprocess.Popen(cmd,
stdout=subprocess.PIPE,
stderr=Transcoder.devnull
Expand Down Expand Up @@ -107,6 +125,7 @@ class AudioTranscode:
Decoder('aac' , ['faad', '-w', 'INPUT']),
Decoder('m4a' , ['faad', '-w', 'INPUT']),
Decoder('wav' , ['cat', 'INPUT']),
Decoder('cue' , ['ffmpeg', '-ss', 'STARTTIME', '-t', 'DURATION', '-i', 'INPUT', '-f', 'wav', '-']),
]

def __init__(self,debug=False):
Expand All @@ -125,7 +144,7 @@ def _filetype(self, filepath):
if '.' in filepath:
return filepath.lower()[filepath.rindex('.')+1:]

def _decode(self, filepath, decoder=None, starttime=0):
def _decode(self, filepath, decoder=None, starttime=0, duration=None):
if not os.path.exists(filepath):
filepath = os.path.abspath(filepath)
raise DecodeError('File not Found! Cannot decode "file" %s'%filepath)
Expand All @@ -139,7 +158,7 @@ def _decode(self, filepath, decoder=None, starttime=0):
break
if self.debug:
print(decoder)
return decoder.decode(filepath, starttime=starttime)
return decoder.decode(filepath, starttime=starttime, duration=duration)

def _encode(self, audio_format, decoder_process, bitrate=None,encoder=None):
if not audio_format in self.availableEncoderFormats():
Expand All @@ -166,11 +185,11 @@ def transcode(self, in_file, out_file, bitrate=None):
fh.close()

def transcodeStream(self, filepath, newformat, bitrate=None,
encoder=None, decoder=None, starttime=0):
encoder=None, decoder=None, starttime=0, duration=None):
decoder_process = None
encoder_process = None
try:
decoder_process = self._decode(filepath, decoder, starttime=starttime)
decoder_process = self._decode(filepath, decoder, starttime=starttime, duration=duration)
encoder_process = self._encode(newformat, decoder_process,bitrate=bitrate,encoder=encoder)
while encoder_process.poll() == None:
data = encoder_process.stdout.read(AudioTranscode.READ_BUFFER)
Expand Down
45 changes: 43 additions & 2 deletions cherrymusicserver/cherrymodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,40 @@ def listdir(self, dirpath, filterstr=''):
musicentry.count_subfolders_and_files()
return musicentries

@classmethod
def addCueSheet(cls, filepath, list):
from cherrymusicserver.cuesheet import Cuesheet
from cherrymusicserver.metainfo import getCueSongInfo
cue = Cuesheet(filepath)
audio_filepath = None
for cdtext in cue.info[0].cdtext:
if cdtext.type == 'FILE':
# Set the actual filepath from the FILE field
audio_filepath = os.path.join(os.path.dirname(filepath), cdtext.value[0])
break
if audio_filepath is None or not os.path.exists(audio_filepath):
log.info(_("Could not find a valid audio file path in cue sheet '%(filepath)s'"), {'filepath': filepath})
return
for track_n, track in enumerate(cue.tracks, 1):
starttime = track.get_start_time()
# We need to know the length of the audio file to get the duration of the last track.
nexttrack = cue.get_next(track)
metainfo = getCueSongInfo(filepath, cue, track_n)
if nexttrack:
track.nextstart = nexttrack.get_start_time()
duration = track.get_length()
elif metainfo and metainfo.length:
duration = metainfo.length
else:
duration = None
list.append(MusicEntry(strippath(filepath), starttime = starttime, duration = duration, metainfo = metainfo))

@classmethod
def addMusicEntry(cls, fullpath, list):
if os.path.isfile(fullpath):
if CherryModel.isplayable(fullpath):
if CherryModel.iscuesheet(fullpath):
CherryModel.addCueSheet(fullpath, list)
elif CherryModel.isplayable(fullpath):
list.append(MusicEntry(strippath(fullpath)))
else:
list.append(MusicEntry(strippath(fullpath), dir=True))
Expand Down Expand Up @@ -313,6 +343,10 @@ def isplayable(cls, filename):
is_empty_file = os.path.getsize(CherryModel.abspath(filename)) == 0
return is_supported_ext and not is_empty_file

@staticmethod
def iscuesheet(filename):
ext = os.path.splitext(filename)[1]
return ext.lower() == '.cue'

def strippath(path):
if path.startswith(cherry.config['media.basedir']):
Expand All @@ -325,7 +359,7 @@ class MusicEntry:
# check if there are playable meadia files or other folders inside
MAX_SUB_FILES_ITER_COUNT = 100

def __init__(self, path, compact=False, dir=False, repr=None, subdircount=0, subfilescount=0):
def __init__(self, path, compact=False, dir=False, repr=None, subdircount=0, subfilescount=0, starttime=None, duration=None, metainfo=None):
self.path = path
self.compact = compact
self.dir = dir
Expand All @@ -336,6 +370,10 @@ def __init__(self, path, compact=False, dir=False, repr=None, subdircount=0, sub
self.subfilescount = subfilescount
# True when the exact amount of files is too big and is estimated
self.subfilesestimate = False
# Times for start and length of the track
self.starttime = starttime
self.duration = duration
self.metainfo = metainfo

def count_subfolders_and_files(self):
if self.dir:
Expand Down Expand Up @@ -381,6 +419,9 @@ def to_dict(self):
return {'type': 'file',
'urlpath': urlpath,
'path': self.path,
'starttime': self.starttime,
'duration': self.duration,
'metainfo': self.metainfo.dict() if self.metainfo is not None else None,
'label': simplename}

def __repr__(self):
Expand Down
130 changes: 130 additions & 0 deletions cherrymusicserver/cuesheet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Python cuesheet parsing
# Copyright (C) 2009-2014 Jon Bergli Heier

# This program 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.
#
# This program 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 this program. If not, see <http://www.gnu.org/licenses/>
#

import re

cdtext_re = {
'REM': r'^(REM) (.+)$',
'PERFORMER': r'^(PERFORMER) "?(.+?)"?$',
'TITLE': r'^(TITLE) "?(.+?)"?$',
#'FILE': r'^(FILE) "?(.+?)"? (BINARY|MOTOROLA|AIFF|WAVE|MP3)$',
# XXX: The above line is the only correct one according to the spec, but some people
# seem to think that putting stuff like FLAC here instead is a good idea.
'FILE': r'^(FILE) "?(.+?)"? (\w+)$',
'TRACK': r'^(TRACK) (\d+) (AUDIO|CDG|MODE1/2048|MODE1/2352|MODE2/2336|MODE2/2352|CDI/2336|CDI2352)$',
'INDEX': r'^(INDEX) (\d+) (\d+):(\d+):(\d+)$',
'FLAGS': r'^((?:DCP|4CH|PRE|SCMS) ?){1,4}$',
'ISRC': r'^(ISRC) (\w{5}\d{7})$',
'SONGWRITER': r'^(SONGWRITER) "?(.+?)"?$',
'CATALOG': r'^(CATALOG) (\d{13})$',
}

for k, v in cdtext_re.items():
cdtext_re[k] = re.compile(v)

class CDText(object):
def __init__(self, str):
name = str.split()[0]
self.re = cdtext_re[name]
l = self.parse(str)
self.type, self.value = l[0], l[1:]
if type(self.value) == tuple and len(self.value) == 1:
self.value = self.value[0]

def __repr__(self):
return '<CDText "%s" "%s">' % (self.type, self.value)

def __str__(self):
return repr(self)

def parse(self, str):
r = self.re.match(str)
if not r:
return None, None
return r.groups()

class FieldDescriptor(object):
def __init__(self, field):
self.field = field

def __get__(self, instance, owner):
def find(name):
for l in instance.cdtext:
if l.type == name:
return l
cdtext = find(self.field)
return cdtext.value if cdtext else None

class Track(object):
def __init__(self):
self.cdtext = []
self.abs_tot, self.abs_end, self.nextstart = 0, 0, None

def add(self, cdtext):
self.cdtext.append(cdtext)

def set_abs_tot(self, tot):
self.abs_tot = tot

def set_abs_end(self, end):
self.abs_end

def get_start_time(self):
index = self.index
return int(index[1])*60 + int(index[2]) + float(index[3])/75

def get_length(self):
return self.nextstart - self.get_start_time()

for f in cdtext_re.keys():
setattr(Track, f.lower(), FieldDescriptor(f))

class Cuesheet(object):
def __init__(self, filename = None, fileobj = None):
if not fileobj and filename:
fileobj = open(filename, 'rb')
if fileobj:
self.parse(fileobj)

def parse(self, f):
info = []
tracks = []
track = Track()
info.append(track)
if not f.read(3) == b'\xef\xbb\xbf':
f.seek(0)
for line in f:
line = line.strip()
line = line.decode('utf-8')
if not len(line):
continue
cdtext = CDText(line)
if cdtext.type == 'TRACK':
track = Track()
tracks.append(track)
track.add(cdtext)
self.info = info
self.tracks = tracks

def get_next(self, track):
found = False
for i in self.tracks:
if found:
return i
elif i == track:
found = True
return None
8 changes: 6 additions & 2 deletions cherrymusicserver/httphandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,14 +252,18 @@ def trans(self, newformat, *path, **params):
path = codecs.decode(codecs.encode(path, 'latin1'), 'utf-8')
fullpath = os.path.join(cherry.config['media.basedir'], path)

starttime = int(params.pop('starttime', 0))
starttime = float(params.pop('starttime', 0))
if 'duration' in params:
duration = float(params.pop('duration'))
else:
duration = None

transcoder = audiotranscode.AudioTranscode()
mimetype = transcoder.mimeType(newformat)
cherrypy.response.headers["Content-Type"] = mimetype
try:
return transcoder.transcodeStream(fullpath, newformat,
bitrate=bitrate, starttime=starttime)
bitrate=bitrate, starttime=starttime, duration=duration)
except audiotranscode.TranscodeError as e:
raise cherrypy.HTTPError(404, e.value)
trans.exposed = True
Expand Down
25 changes: 24 additions & 1 deletion cherrymusicserver/metainfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
#

from cherrymusicserver import log
import sys
import sys, os

from tinytag import TinyTag

Expand All @@ -50,7 +50,30 @@ def dict(self):
'length': self.length
}

def getCueSongInfo(filepath, cue, track_n):
info = cue.info[0]
artist = info.performer or '-'
album = info.title or '-'
title = '-'
track = cue.tracks[track_n-1]
artist = track.performer or artist
title = track.title or title
if track_n < len(cue.tracks):
track.nextstart = cue.get_next(track).get_start_time()
audiolength = track.get_length()
else:
try:
audiofilepath = os.path.join(os.path.dirname(filepath), info.file[0])
tag = TinyTag.get(audiofilepath)
except Exception:
audiolength = None
log.warn(_("Couldn't get length of '%s', setting 0"), audiofilepath)
else:
audiolength = tag.duration - track.get_start_time()
return Metainfo(artist, album, title, track_n, audiolength)

def getSongInfo(filepath):
ext = os.path.splitext(filepath)[1]
try:
tag = TinyTag.get(filepath)
except LookupError:
Expand Down
2 changes: 1 addition & 1 deletion cherrymusicserver/test/test_httphandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ def test_trans(self):

httphandler.HTTPHandler(config).trans('newformat', 'path', bitrate=111)

transcoder.transcodeStream.assert_called_with(expectPath, 'newformat', bitrate=111, starttime=0)
transcoder.transcodeStream.assert_called_with(expectPath, 'newformat', bitrate=111, starttime=0, duration=None)


if __name__ == "__main__":
Expand Down
Loading