• Categories
    • Recent
    • Tags
    • Popular
    • Users
    • Groups
    • Register
    • Login

    iOS sync script for Linux

    Scheduled Pinned Locked Moved
    General Discussion
    1
    2
    1.1k
    Loading More Posts
    • Oldest to Newest
    • Newest to Oldest
    • Most Votes
    Reply
    • Reply as topic
    Log in to reply
    This topic has been deleted. Only users with topic management privileges can see it.
    • T
      tevon
      last edited by

      Sharing a quick sync script for Linux that helps copy files from your playlists to a mounted iPhone. Hope someone else finds it useful. No warranty, blah blah blah.

      • Prerequisites: Python 3, ifuse, your favorite Linux flavor.
      • Make sure you update variables to match your own collection (lines 21-32).
      • This requires an app OTHER THAN the default Music app, see https://libimobiledevice.org/#faq. (I currently use BTRamp, which isn't great but does well enough.) Use ifuse --list-apps to find your own app's identifier.
      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 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 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 (including / prefix)
      music_db_file = '/.local/share/strawberry/strawberry/strawberry.db'
      # Change `INFO` to `DEBUG` here for more precise troubleshooting
      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.debug('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'    Found {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 links, 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'    Added {len(playlist_songs)} songs from playlist #{playlist_id}.')
          db.close()
          logging.debug(f'    Found {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'    iPhone 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 BTRAmp stuff
                  if file == 'PlayerLog.log':
                      continue
                  dst_path = Path(path + '/' + file)
                  relative_path = str(Path(path + '/' + file).relative_to(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'    Overwriting 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:
                          unchanged += 1
                          logging.debug(f'    Skipping unchanged file: {relative_path}')
                      # Remove handled file from queue
                      del sync_queue[relative_path]
                  # Destination file isn't in the queue, delete it
                  else:
                      logging.warning(f'    Removing 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'    Removing 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.debug(f'    {folder}')
                  makedirs(folder)
              logging.debug(f'        {song}')
              copyfile(Path(src_folder + '/' + song), dst_path)
          logging.info(f'    {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()
      
      T 1 Reply Last reply Reply Quote 1
      • T
        tevon @tevon
        last edited by

        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()
        
        1 Reply Last reply Reply Quote 1
        • First post
          Last post
        Powered by NodeBB | Contributors