Skip to content

Instantly share code, notes, and snippets.

@FiXato
Last active September 25, 2020 13:56
Show Gist options
  • Save FiXato/28be2bde6c93fa47c087ed213fc5b27b to your computer and use it in GitHub Desktop.
Save FiXato/28be2bde6c93fa47c087ed213fc5b27b to your computer and use it in GitHub Desktop.
A proof-of-concept script based on a conversation over on #Mastodon at mastodon.social/@FiXato/104738706980490091, It tries to provide a CLI solution to copying the music referenced in an M3U8 playlist to a separate directory so it can be more easily synced to another device such as an Android phone, while retaining the playlist order.
#!/usr/bin/env python3
# encoding: utf-8
#
# copy_playlist_contents.py
# (c) 2020-08-24 Filip H.F. "FiXato" Slagter, https://contact.fixato.org
# version 2020-08-24 04:55
#
# A proof-of-concept script based on a conversation over on #Mastodon at https://mastodon.social/@FiXato/104738706980490091
# It tries to provide a CLI solution to copying the music referenced in an M3U8 playlist to a separate directory so it can be more easily synced to another device such as an Android phone, while retaining the playlist order.
#
# So far I've only tested it on a Windows 10 machine, with Python 3.8.2, with the script being executed under PowerShell Core 7 Preview (https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-core-on-windows?view=powershell-7)
# with a sample playlist created in VLC on Windows, containing music files in:
# - a subdirectory of the directory in which the playlist file is located
# - the root directory of another disk partition
# - a directory in a network share hosted on another Windows (7) machine.
#
# It supports:
# - either flattening the source directory structure, or retaining it (in addition to re-creating the structure in a relative 'mnt' directory for cross-device files)
# - creating a subdirectory for each playlist
# - recursing through source folders
# - renaming destination files based on metadata such as artist, album, tracknumber and title. (`RECURSE` needs to be `True` for files to be renamed if they are in a playlist's folder)
#
# This script is provided AS-IS, and use at your own risk. It's a proof-of-concept, and likely won't get many updates.
#
# Requirements:
# `pip install m3u8 mutagen filetype pathvalidate blessed`
# (and potentially `pip install urllib urlparse pathlib`, though I have not actually tested it under Python2)
#
# Licensed under:
# MIT License
#
# Copyright (c) 2020 Filip H.F. "FiXato" Slagter
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import os
import sys
import re
try:
from urllib.parse import urlparse, unquote
from urllib.request import url2pathname
except ImportError:
# backwards compatibility
from urlparse import urlparse
from urllib import unquote, url2pathname
import shutil
import m3u8
from pathlib import Path
import mutagen
import filetype
from pathvalidate import sanitize_filename
from blessed import Terminal
DEBUG = True
SUBDIR_FOR_EACH_PLAYLIST = True
MUSIC_DIR = Path.home().joinpath('Music')
DEST_DIR = MUSIC_DIR.joinpath('exported')
RECURSE = False
FLATTEN = False
RENAME = True
term = Terminal()
term.exit_fullscreen()
#TODO: support specifying the playlists_folder(s) from the command line.
playlists_folder = MUSIC_DIR
def uri_to_path(uri):
parsed = urlparse(uri)
if parsed.scheme == "":
return unquote(uri)
if parsed.scheme == "file":
return Path(
unquote(
re.sub('^file:', '', uri)
)
)
#FIXME: probably don't need this next part anymore
host = f"{os.path.sep}{os.path.sep}{parsed.netloc}{os.path.sep}"
return os.path.normpath(
os.path.join(host, url2pathname(unquote(parsed.path)))
)
def debug(obj):
if DEBUG:
print(f"{term.greenyellow('DEBUG:')} {obj}", file=sys.stderr)
def success(obj):
print(f"{term.green('DEBUG:')} {obj}", file=sys.stdout)
def warn(obj):
print(f"{term.orangered('WARN:')} {obj}", file=sys.stderr)
def error(obj):
print(f"{term.red('ERROR:')} {obj}", file=sys.stderr)
def filename_from_trackinfo(trackinfo, suffix='.mp3'):
fname = ""
if trackinfo.get('artist'):
fname += f"{', '.join(trackinfo.get('artist'))} - "
if trackinfo.get('album'):
fname += f"[{', '.join(trackinfo.get('album'))}"
if trackinfo.get('tracknumber'):
fname += f" - #{trackinfo.get('tracknumber')[0]}"
fname += '] - '
if trackinfo.get('title'):
fname += f"{', '.join(trackinfo.get('title'))}{suffix}"
return fname
def build_new_filename(src):
trackinfo = mutagen.File(src, easy=True)
if trackinfo != None:
fname = filename_from_trackinfo(trackinfo, suffix=src.suffix)
else:
warn(f"Not a recognised media metadata format for: '{src}'")
fname = str(src.parent.stem) + f" - {src.name}"
debug(f"New filename: {fname}")
fname = fname.replace(':', ' - ')
fname = re.sub('\s+', ' ', fname)
fname = fname.replace('/', '/')
fname = sanitize_filename(fname)
debug(f"New sanitised filename: {fname}")
return fname
def supported_media_type(file):
kind = filetype.guess(str(file))
if not kind:
return False
mime = kind.mime
media_type = mime.split('/')[0]
if media_type in ['video', 'audio']:
return True
else:
debug('Not a media file-type for "{file}": {mime}')
return False
for playlist_file in playlists_folder.glob('*.m3u8'):
playlist_parent_dir = playlist_file.parent
#TODO: If m3u8 supports metadata for naming a playlist, it could be extracted here
playlist_name = playlist_file.stem
playlist = m3u8.load(str(playlist_file))
new_playlist = []
dest_subdir = DEST_DIR
if SUBDIR_FOR_EACH_PLAYLIST:
dest_subdir = dest_subdir.joinpath(playlist_name)
for file in playlist.files:
unescaped_file = uri_to_path(file)
src = playlist_parent_dir.joinpath(unescaped_file)
if not src.exists():
warn(f"Source does not exist: '{src}'")
continue
if RECURSE and src.is_dir():
# TODO: optionally limit to known file types
files = src.rglob('*')
else:
files = [src]
for src in files:
debug(f"processing {src}:")
dest = dest_subdir
if not FLATTEN:
debug(f"Drives: {[src.drive, playlist_parent_dir.drive]}")
if src.drive == playlist_parent_dir.drive:
relpath = src.parent.resolve().relative_to(playlist_parent_dir.resolve())
debug(f"RELPATH: {relpath}")
dest = dest.joinpath(relpath)
else:
parts = src.parent.resolve().absolute().parts[1:]
debug(f"Parts: {parts}")
dest = dest.joinpath('mnt', src.drive.replace(':', '').replace(f"{os.path.sep}{os.path.sep}", '').replace(os.path.sep, '-'), *parts)
debug("Post-FLATTEN check:" + str([str(src), str(dest)]))
if RENAME and not src.is_dir():
fname = build_new_filename(src)
else:
fname = src.name
dest = dest.joinpath(fname)
if dest.exists():
debug("Already exists: " + str(dest))
if dest.is_dir() or supported_media_type(dest):
new_playlist.append(dest)
else:
debug(f'Copying "{src}" to "{dest}"')
if src.is_dir():
debug(f"src {src} is dir")
shutil.copytree(src, dest)
new_playlist.append(dest)
success(f'Successfully copied directory "{src}" to "{dest}"')
else:
debug(f"src {src} is file")
if not dest.parent.exists():
dest.parent.mkdir(parents=True)
shutil.copy2(src, dest)
if supported_media_type(dest):
new_playlist.append(dest)
if len(new_playlist) > 0:
if not dest_subdir.exists():
dest_subdir.mkdir(parents=True)
new_playlist_file = dest_subdir.joinpath(playlist_file.name)
with open(new_playlist_file,'w') as f:
for playlist_entry in new_playlist:
if playlist_entry.drive == new_playlist_file.drive:
f.write(playlist_entry.relative_to(dest_subdir).as_posix() + "\n")
else:
f.write(playlist_entry.resolve().absolute().as_uri() + "\n")
#TODO: write tests for known filepaths
MIT License
Copyright (c) 2020 Filip H.F. "FiXato" Slagter
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment