xspf2m3u

simple XSPF to M3U conversion
git clone https://git.ce9e.org/xspf2m3u.git

commit
5dde63bd24c25aeeb5fff7c638850eb702a4723c
parent
0bbfcf656b5d71982d8807aac0de300d9cbd7b8f
Author
Tobias Bengfort <tobias.bengfort@posteo.de>
Date
2023-02-20 06:14
replace tabs by spaces

flake8 no longer supports tabs

Diffstat

M m3u2xspf.py 92 ++++++++++++++++++++++++++++++------------------------------
M xspf2m3u.py 226 +++++++++++++++++++++++++++++++------------------------------

2 files changed, 160 insertions, 158 deletions


diff --git a/m3u2xspf.py b/m3u2xspf.py

@@ -6,71 +6,71 @@ import argparse
    6     6 from xml.sax.saxutils import escape
    7     7 
    8     8 try:
    9    -1 	import mutagen
   -1     9     import mutagen
   10    10 except ImportError:
   11    -1 	mutagen = None
   -1    11     mutagen = None
   12    12 
   13    13 KEYS = {
   14    -1 	'location': 'location',
   15    -1 	'album': 'album',
   16    -1 	'artist': 'creator',
   17    -1 	'title': 'title',
   -1    14     'location': 'location',
   -1    15     'album': 'album',
   -1    16     'artist': 'creator',
   -1    17     'title': 'title',
   18    18 }
   19    19 
   20    20 
   21    21 def unlist(s):
   22    -1 	return s if isinstance(s, str) else '; '.join(s)
   -1    22     return s if isinstance(s, str) else '; '.join(s)
   23    23 
   24    24 
   25    25 def get_tags(path):
   26    -1 	if not mutagen or not os.path.exists(path):
   27    -1 		return {'location': path}
   28    -1 	data = mutagen.File(path, easy=True)
   29    -1 	if data:
   30    -1 		return data
   31    -1 	else:
   32    -1 		return {'location': path}
   -1    26     if not mutagen or not os.path.exists(path):
   -1    27         return {'location': path}
   -1    28     data = mutagen.File(path, easy=True)
   -1    29     if data:
   -1    30         return data
   -1    31     else:
   -1    32         return {'location': path}
   33    33 
   34    34 
   35    35 def iter_lines(path):
   36    -1 	root = os.path.dirname(path)
   37    -1 	with open(sys.argv[1]) as fh:
   38    -1 		for line in fh:
   39    -1 			line = line.rstrip()
   40    -1 			if line.startswith('#'):
   41    -1 				pass
   42    -1 			elif line.startswith('http'):
   43    -1 				yield {'location': line}
   44    -1 			else:
   45    -1 				if not line.startswith('/'):
   46    -1 					line = os.path.join(root, line)
   47    -1 				yield get_tags(line)
   -1    36     root = os.path.dirname(path)
   -1    37     with open(sys.argv[1]) as fh:
   -1    38         for line in fh:
   -1    39             line = line.rstrip()
   -1    40             if line.startswith('#'):
   -1    41                 pass
   -1    42             elif line.startswith('http'):
   -1    43                 yield {'location': line}
   -1    44             else:
   -1    45                 if not line.startswith('/'):
   -1    46                     line = os.path.join(root, line)
   -1    47                 yield get_tags(line)
   48    48 
   49    49 
   50    50 def parse_args():
   51    -1 	parser = argparse.ArgumentParser()
   52    -1 	parser.add_argument('src')
   53    -1 	return parser.parse_args()
   -1    51     parser = argparse.ArgumentParser()
   -1    52     parser.add_argument('src')
   -1    53     return parser.parse_args()
   54    54 
   55    55 
   56    56 def main():
   57    -1 	args = parse_args()
   58    -1 
   59    -1 	print('<?xml version="1.0" ?>')
   60    -1 	print('<playlist version="1" xmlns="http://xspf.org/ns/0/">')
   61    -1 	print('  <trackList>')
   62    -1 	for entry in iter_lines(args.src):
   63    -1 		print('    <track>')
   64    -1 		for k, v in sorted(entry.items()):
   65    -1 			if k in KEYS:
   66    -1 				print('      <{key}>{value}</{key}>'.format(
   67    -1 					key=escape(KEYS[k]),
   68    -1 					value=escape(unlist(v)),
   69    -1 				))
   70    -1 		print('    </track>')
   71    -1 	print('  </trackList>')
   72    -1 	print('</playlist>')
   -1    57     args = parse_args()
   -1    58 
   -1    59     print('<?xml version="1.0" ?>')
   -1    60     print('<playlist version="1" xmlns="http://xspf.org/ns/0/">')
   -1    61     print('  <trackList>')
   -1    62     for entry in iter_lines(args.src):
   -1    63         print('    <track>')
   -1    64         for k, v in sorted(entry.items()):
   -1    65             if k in KEYS:
   -1    66                 print('      <{key}>{value}</{key}>'.format(
   -1    67                     key=escape(KEYS[k]),
   -1    68                     value=escape(unlist(v)),
   -1    69                 ))
   -1    70         print('    </track>')
   -1    71     print('  </trackList>')
   -1    72     print('</playlist>')
   73    73 
   74    74 
   75    75 if __name__ == '__main__':
   76    -1 	main()
   -1    76     main()

diff --git a/xspf2m3u.py b/xspf2m3u.py

@@ -9,15 +9,15 @@ from time import sleep
    9     9 from xml.etree import ElementTree
   10    10 
   11    11 try:
   12    -1 	import youtube_dl
   -1    12     import youtube_dl
   13    13 
   14    -1 	ydl = youtube_dl.YoutubeDL(params={
   15    -1 		'noplaylist': True,
   16    -1 		'quiet': True,
   17    -1 	})
   18    -1 	ydl_selector = ydl.build_format_selector('bestaudio/best')
   -1    14     ydl = youtube_dl.YoutubeDL(params={
   -1    15         'noplaylist': True,
   -1    16         'quiet': True,
   -1    17     })
   -1    18     ydl_selector = ydl.build_format_selector('bestaudio/best')
   19    19 except ImportError:
   20    -1 	youtube_dl = None
   -1    20     youtube_dl = None
   21    21 
   22    22 NS = '{http://xspf.org/ns/0/}'
   23    23 EXTS = ['mp3', 'ogg', 'opus', 'mp4', 'm4a', 'wav', 'flac', 'wma']
@@ -27,139 +27,141 @@ __version__ = '0.0.0'
   27    27 
   28    28 
   29    29 class DownloadPool(set):
   30    -1 	def log(self, s):
   31    -1 		print(s, file=sys.stderr, end='\r')
   -1    30     def log(self, s):
   -1    31         print(s, file=sys.stderr, end='\r')
   32    32 
   33    -1 	def download(self, url, path):
   34    -1 		self.add(subprocess.Popen(['curl', url, '-s', '-o', path]))
   -1    33     def download(self, url, path):
   -1    34         self.add(subprocess.Popen(['curl', url, '-s', '-o', path]))
   35    35 
   36    -1 	def poll(self):
   37    -1 		for p in list(self):
   38    -1 			if p.poll() is not None:
   39    -1 				self.remove(p)
   40    -1 		return len(self)
   -1    36     def poll(self):
   -1    37         for p in list(self):
   -1    38             if p.poll() is not None:
   -1    39                 self.remove(p)
   -1    40         return len(self)
   41    41 
   42    -1 	def __enter__(self):
   43    -1 		return self
   -1    42     def __enter__(self):
   -1    43         return self
   44    44 
   45    -1 	def __exit__(self, exc_type, exc_value, traceback):
   46    -1 		if exc_type is None:
   47    -1 			while self.poll():
   48    -1 				self.log('{} downloads still active'.format(len(self)))
   49    -1 				sleep(5)
   50    -1 		else:
   51    -1 			for p in self:
   52    -1 				p.kill()
   -1    45     def __exit__(self, exc_type, exc_value, traceback):
   -1    46         if exc_type is None:
   -1    47             while self.poll():
   -1    48                 self.log('{} downloads still active'.format(len(self)))
   -1    49                 sleep(5)
   -1    50         else:
   -1    51             for p in self:
   -1    52                 p.kill()
   53    53 
   54    54 
   55    55 def iter_files(folder):
   56    -1 	for dirpath, dirnames, filenames in os.walk(folder):
   57    -1 		for filename in filenames:
   58    -1 			if filename.rsplit('.', 1)[-1] in EXTS:
   59    -1 				yield dirpath, filename
   -1    56     for dirpath, dirnames, filenames in os.walk(folder):
   -1    57         for filename in filenames:
   -1    58             if filename.rsplit('.', 1)[-1] in EXTS:
   -1    59                 yield dirpath, filename
   60    60 
   61    61 
   62    62 def simplify(s):
   63    -1 	s = s.lower()
   64    -1 	s = s.encode('ascii', errors='replace').decode('ascii')
   65    -1 	return CHARS.sub('', s)
   -1    63     s = s.lower()
   -1    64     s = s.encode('ascii', errors='replace').decode('ascii')
   -1    65     return CHARS.sub('', s)
   66    66 
   67    67 
   68    68 def find_by_title(title, fields, files):
   69    -1 	for dirpath, filename in files:
   70    -1 		path = os.path.join(dirpath, filename)
   71    -1 		if (
   72    -1 			simplify(title) in simplify(filename)
   73    -1 			and all(simplify(o) in simplify(path) for o in fields)
   74    -1 		):
   75    -1 			return path
   -1    69     for dirpath, filename in files:
   -1    70         path = os.path.join(dirpath, filename)
   -1    71         if (
   -1    72             simplify(title) in simplify(filename)
   -1    73             and all(simplify(o) in simplify(path) for o in fields)
   -1    74         ):
   -1    75             return path
   76    76 
   77    77 
   78    78 def search_youtube(q):
   79    -1 	# result expires in 6h
   80    -1 	try:
   81    -1 		info = ydl.extract_info('ytsearch:' + ' '.join(q), download=False)
   82    -1 	except youtube_dl.utils.DownloadError:
   83    -1 		return None, None
   84    -1 	if not info['entries']:
   85    -1 		return None, None
   86    -1 	info = info['entries'][0]
   87    -1 	try:
   88    -1 		_format = next(ydl_selector({'formats': info['formats']}))
   89    -1 	except StopIteration:
   90    -1 		return None, None
   91    -1 	filename = '{}-{}.{}'.format(
   92    -1 		info['title'].replace('/', '_'),
   93    -1 		info['id'],
   94    -1 		_format['ext'],
   95    -1 	)
   96    -1 	return _format['url'], filename
   -1    79     # result expires in 6h
   -1    80     try:
   -1    81         info = ydl.extract_info('ytsearch:' + ' '.join(q), download=False)
   -1    82     except youtube_dl.utils.DownloadError:
   -1    83         return None, None
   -1    84     if not info['entries']:
   -1    85         return None, None
   -1    86     info = info['entries'][0]
   -1    87     try:
   -1    88         _format = next(ydl_selector({'formats': info['formats']}))
   -1    89     except StopIteration:
   -1    90         return None, None
   -1    91     filename = '{}-{}.{}'.format(
   -1    92         info['title'].replace('/', '_'),
   -1    93         info['id'],
   -1    94         _format['ext'],
   -1    95     )
   -1    96     return _format['url'], filename
   97    97 
   98    98 
   99    99 def iter_tracks(src):
  100    -1 	root = ElementTree.parse(src).getroot()
  101    -1 	for e in root.iter(NS + 'track'):
  102    -1 		track = {}
  103    -1 		for tag in ['location', 'title', 'creator', 'album', 'annotation']:
  104    -1 			field = e.find(NS + tag)
  105    -1 			if field is None:
  106    -1 				track[tag] = None
  107    -1 			else:
  108    -1 				track[tag] = field.text
  109    -1 		yield track
   -1   100     root = ElementTree.parse(src).getroot()
   -1   101     for e in root.iter(NS + 'track'):
   -1   102         track = {}
   -1   103         for tag in ['location', 'title', 'creator', 'album', 'annotation']:
   -1   104             field = e.find(NS + tag)
   -1   105             if field is None:
   -1   106                 track[tag] = None
   -1   107             else:
   -1   108                 track[tag] = field.text
   -1   109         yield track
  110   110 
  111   111 
  112   112 def hard_link(location, outdir):
  113    -1 	os.makedirs(outdir, exist_ok=True)
  114    -1 	filename = os.path.basename(location)
  115    -1 	path = os.path.join(outdir, filename)
  116    -1 	os.link(location, path)
  117    -1 	return path
   -1   113     os.makedirs(outdir, exist_ok=True)
   -1   114     filename = os.path.basename(location)
   -1   115     path = os.path.join(outdir, filename)
   -1   116     os.link(location, path)
   -1   117     return path
  118   118 
  119   119 
  120   120 def parse_args():
  121    -1 	parser = argparse.ArgumentParser()
  122    -1 	parser.add_argument('src')
  123    -1 	parser.add_argument('folder')
  124    -1 	parser.add_argument('-Y', '--youtube', action='store_true')
  125    -1 	parser.add_argument('-O', '--outdir')
  126    -1 	return parser.parse_args()
   -1   121     parser = argparse.ArgumentParser()
   -1   122     parser.add_argument('src')
   -1   123     parser.add_argument('folder')
   -1   124     parser.add_argument('-Y', '--youtube', action='store_true')
   -1   125     parser.add_argument('-O', '--outdir')
   -1   126     return parser.parse_args()
  127   127 
  128   128 
  129   129 def main():
  130    -1 	args = parse_args()
  131    -1 	files = list(iter_files(args.folder))
  132    -1 
  133    -1 	if args.outdir:
  134    -1 		os.makedirs(args.outdir)
  135    -1 
  136    -1 	with DownloadPool() as pool:
  137    -1 		for track in iter_tracks(args.src):
  138    -1 			location = track['location']
  139    -1 
  140    -1 			if location is None:
  141    -1 				context_key = ['creator', 'annotation']
  142    -1 				context = [track[k] for k in context_key if track[k]]
  143    -1 				location = find_by_title(track['title'], context, files)
  144    -1 				if location and args.outdir:
  145    -1 					location = hard_link(location, args.outdir)
  146    -1 
  147    -1 			if location is None and args.youtube and youtube_dl:
  148    -1 				url, filename = search_youtube([q for q in track.values() if q])
  149    -1 				if url is None:
  150    -1 					location = None
  151    -1 				elif args.outdir:
  152    -1 					location = os.path.join(args.outdir, filename)
  153    -1 					pool.download(url, location)
  154    -1 				else:
  155    -1 					location = url
  156    -1 
  157    -1 			if location is None:
  158    -1 				s = ' - '.join('{}: {}'.format(k, v) for k, v in track.items())
  159    -1 				print('# Warning: ' + s)
  160    -1 			else:
  161    -1 				print(location)
   -1   130     args = parse_args()
   -1   131     files = list(iter_files(args.folder))
   -1   132 
   -1   133     if args.outdir:
   -1   134         os.makedirs(args.outdir)
   -1   135 
   -1   136     with DownloadPool() as pool:
   -1   137         for track in iter_tracks(args.src):
   -1   138             location = track['location']
   -1   139 
   -1   140             if location is None:
   -1   141                 context_key = ['creator', 'annotation']
   -1   142                 context = [track[k] for k in context_key if track[k]]
   -1   143                 location = find_by_title(track['title'], context, files)
   -1   144                 if location and args.outdir:
   -1   145                     location = hard_link(location, args.outdir)
   -1   146 
   -1   147             if location is None and args.youtube and youtube_dl:
   -1   148                 url, filename = search_youtube(
   -1   149                     [q for q in track.values() if q]
   -1   150                 )
   -1   151                 if url is None:
   -1   152                     location = None
   -1   153                 elif args.outdir:
   -1   154                     location = os.path.join(args.outdir, filename)
   -1   155                     pool.download(url, location)
   -1   156                 else:
   -1   157                     location = url
   -1   158 
   -1   159             if location is None:
   -1   160                 s = ' - '.join('{}: {}'.format(k, v) for k, v in track.items())
   -1   161                 print('# Warning: ' + s)
   -1   162             else:
   -1   163                 print(location)
  162   164 
  163   165 
  164   166 if __name__ == '__main__':
  165    -1 	main()
   -1   167     main()