Last active
September 25, 2020 13:56
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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