I can't edit the original post, so here's a new version that properly handles accented characters in folders/files so that they don't perpetually re-copy. (For example, "Chopin, Frédéric".) Previous disclaimers and notes apply.
Separately, BTRamp is okay but tends to crash after a few hours, and won't shuffle more than 200 songs at a time. If anyone has iOS app suggestions that focus on locally stored songs, support CarPlay, and shuffle songs matching a detected genre ID3 tag, I'd love to hear about alternatives.
import logging
import sqlite3
from os import makedirs, remove, rmdir, walk
from os.path import getmtime
from pathlib import Path
from shutil import copyfile
from subprocess import call
from unicodedata import normalize
from urllib.parse import unquote, urlparse
def error_out(error_msg, db_conn=None):
'''
Print a message, close the Strawberry DB if open, exit with an error.
'''
logging.error(error_msg)
if db_conn:
db_conn.close()
exit(1)
# The iOS identifier for your music app
ios_appname = 'com.btrlabs.btramp'
# Names of files your music app has internally that you DON'T want deleted
skip_files = ['PlayerLog.log']
# Names of playlists you DON'T want to sync
playlists_to_ignore = ['Unlistened', 'Test']
# The base folder where your collection is located
src_folder = '/mnt/music'
# The folder where your iPhone will be mounted
dst_folder = '/mnt/iphone'
# Your Strawberry SQLite DB location, relative to $HOME
music_db_file = '.local/share/strawberry/strawberry/strawberry.db'
# Adjust verbosity up to 'WARNING'/'ERROR' or down to 'DEBUG'
logging.basicConfig(format='%(message)s', level=logging.INFO)
logging.debug(f'Connecting to DB at $HOME/{music_db_file} ...')
try:
home_folder_path = str(Path.home())
db = sqlite3.connect(home_folder_path + '/' + music_db_file)
c = db.cursor()
except Exception as e:
error_out(f'Unable to connect to Strawberry DB: {e}', db)
logging.info('Building queue from DB ...')
try:
sync_queue = {}
raw_playlist_ids = c.execute(
"""
SELECT ROWID
FROM playlists
WHERE name NOT IN ({})
""".format(','.join('?' * len(playlists_to_ignore))),
tuple(playlists_to_ignore)
).fetchall()
playlist_ids = [i[0] for i in raw_playlist_ids]
logging.debug(f"\tFound {len(playlist_ids)} playlists.")
for playlist_id in playlist_ids:
playlist_songs = c.execute(
"""
SELECT
songs.url,
songs.mtime
FROM songs
LEFT JOIN playlist_items
ON songs.rowid = playlist_items.collection_id
WHERE playlist_items.playlist = ?
""",
(playlist_id,)
).fetchall()
# Add song to queue, and also convert URLs to relative paths, e.g.
# file:///path/to/artist/album/song.mp3 to artist/album/song.mp3
for song in playlist_songs:
sync_queue[str(Path(unquote(urlparse(song[0]).path)).relative_to('/mnt/music'))] = song[1]
logging.debug(f"\tAdded {len(playlist_songs)} songs from playlist #{playlist_id}.")
db.close()
logging.info(f"\tFound {str(len(sync_queue))} files for sync.")
except Exception as e:
error_out(f'Unable to build queue: {e}', db)
logging.debug('Checking iPhone mount status ...')
try:
if not Path(dst_folder).exists():
logging.debug(f' Creating {dst_folder} ...')
makedirs(dst_folder)
if not Path(dst_folder).is_mount():
logging.debug(f' Mounting iPhone at {dst_folder}.')
ret = call(f"ifuse --documents {ios_appname} {dst_folder}", shell=True)
if ret != 0:
raise Exception(f'ifuse returned {ret}')
else:
logging.debug(f"\tiPhone is already mounted at {dst_folder}.")
except Exception as e:
error_out(f'Unable to mount iPhone: {e}')
logging.info('Syncing files ...')
try:
unchanged = overwritten = removed = 0
for (path, folders, files) in walk(dst_folder):
for file in files:
# Ignore music player stuff
if file in skip_files:
continue
# We have to convert accented characters from NFC (Linux) to NFD (Mac)
# as we compare and store each file + folder
dst_path = Path(normalize('NFD', path + '/' + file))
relative_path = str(
Path(normalize('NFC', path) + '/' + normalize('NFC', file))
.relative_to(normalize('NFC', dst_folder))
)
# Same filename exists in src + dst: recopy if newer
if relative_path in sync_queue:
if sync_queue[relative_path] > getmtime(dst_path):
if Path(src_folder + '/' + relative_path).exists():
logging.warning(f"\tOverwriting older file: {relative_path}")
overwritten += 1
copyfile(Path(src_folder + '/' + relative_path), dst_path)
else:
raise Exception(f'Unable to read source file {relative_path}')
else:
logging.debug(f"\tSkipping unchanged: {relative_path}")
unchanged += 1
# Remove handled file from queue
del sync_queue[relative_path]
# Destination file isn't in the queue, delete it
else:
logging.warning(f"\tRemoving file: {relative_path}")
removed += 1
remove(dst_path)
# Clean up empty folders; yes, there's potentially minor churn here
if folders == [] and files == [] and '_btramp' not in path:
logging.warning(f"\tRemoving folder: {path}")
rmdir(path)
newsongs = len(sync_queue)
for song in sync_queue.keys():
dst_path = Path(dst_folder + '/' + song)
folder = dst_path.parent
if not folder.exists():
logging.info(f"\tAdding folder: {folder}")
makedirs(folder)
logging.info(f"\tAdding file: {song}")
copyfile(Path(src_folder + '/' + song), dst_path)
logging.info(f"\t{newsongs} new, {unchanged} unchanged, {overwritten} updated, {removed} deleted.")
except Exception as e:
error_out(f'Unable to sync files: {e}')
logging.debug('Unmounting iPhone ...')
try:
ret = call(f'fusermount -u {dst_folder}', shell=True)
if ret != 0:
raise Exception(f'fusermount returned {ret}')
except Exception as e:
error_out(f'Unable to unmount iPhone: {e}')
logging.info('Sync complete.')
exit()