# This file is part of beets. # # 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. """Adds support for ipfs. Requires go-ipfs and a running ipfs daemon """ from beets import ui, util, library, config from beets.plugins import BeetsPlugin import subprocess import shutil import os import tempfile class IPFSPlugin(BeetsPlugin): def __init__(self): super().__init__() self.config.add({ 'auto': True, 'nocopy': False, }) if self.config['auto']: self.import_stages = [self.auto_add] def commands(self): cmd = ui.Subcommand('ipfs', help='interact with ipfs') cmd.parser.add_option('-a', '--add', dest='add', action='store_true', help='Add to ipfs') cmd.parser.add_option('-g', '--get', dest='get', action='store_true', help='Get from ipfs') cmd.parser.add_option('-p', '--publish', dest='publish', action='store_true', help='Publish local library to ipfs') cmd.parser.add_option('-i', '--import', dest='_import', action='store_true', help='Import remote library from ipfs') cmd.parser.add_option('-l', '--list', dest='_list', action='store_true', help='Query imported libraries') cmd.parser.add_option('-m', '--play', dest='play', action='store_true', help='Play music from remote libraries') def func(lib, opts, args): if opts.add: for album in lib.albums(ui.decargs(args)): if len(album.items()) == 0: self._log.info('{0} does not contain items, aborting', album) self.ipfs_add(album) album.store() if opts.get: self.ipfs_get(lib, ui.decargs(args)) if opts.publish: self.ipfs_publish(lib) if opts._import: self.ipfs_import(lib, ui.decargs(args)) if opts._list: self.ipfs_list(lib, ui.decargs(args)) if opts.play: self.ipfs_play(lib, opts, ui.decargs(args)) cmd.func = func return [cmd] def auto_add(self, session, task): if task.is_album: if self.ipfs_add(task.album): task.album.store() def ipfs_play(self, lib, opts, args): from beetsplug.play import PlayPlugin jlib = self.get_remote_lib(lib) player = PlayPlugin() config['play']['relative_to'] = None player.album = True player.play_music(jlib, player, args) def ipfs_add(self, album): try: album_dir = album.item_dir() except AttributeError: return False try: if album.ipfs: self._log.debug('{0} already added', album_dir) # Already added to ipfs return False except AttributeError: pass self._log.info('Adding {0} to ipfs', album_dir) if self.config['nocopy']: cmd = "ipfs add --nocopy -q -r".split() else: cmd = "ipfs add -q -r".split() cmd.append(album_dir) try: output = util.command_output(cmd).stdout.split() except (OSError, subprocess.CalledProcessError) as exc: self._log.error('Failed to add {0}, error: {1}', album_dir, exc) return False length = len(output) for linenr, line in enumerate(output): line = line.strip() if linenr == length - 1: # last printed line is the album hash self._log.info("album: {0}", line) album.ipfs = line else: try: item = album.items()[linenr] self._log.info("item: {0}", line) item.ipfs = line item.store() except IndexError: # if there's non music files in the to-add folder they'll # get ignored here pass return True def ipfs_get(self, lib, query): query = query[0] # Check if query is a hash # TODO: generalize to other hashes; probably use a multihash # implementation if query.startswith("Qm") and len(query) == 46: self.ipfs_get_from_hash(lib, query) else: albums = self.query(lib, query) for album in albums: self.ipfs_get_from_hash(lib, album.ipfs) def ipfs_get_from_hash(self, lib, _hash): try: cmd = "ipfs get".split() cmd.append(_hash) util.command_output(cmd) except (OSError, subprocess.CalledProcessError) as err: self._log.error('Failed to get {0} from ipfs.\n{1}', _hash, err.output) return False self._log.info('Getting {0} from ipfs', _hash) imp = ui.commands.TerminalImportSession(lib, loghandler=None, query=None, paths=[_hash]) imp.run() shutil.rmtree(_hash) def ipfs_publish(self, lib): with tempfile.NamedTemporaryFile() as tmp: self.ipfs_added_albums(lib, tmp.name) try: if self.config['nocopy']: cmd = "ipfs add --nocopy -q ".split() else: cmd = "ipfs add -q ".split() cmd.append(tmp.name) output = util.command_output(cmd).stdout except (OSError, subprocess.CalledProcessError) as err: msg = f"Failed to publish library. Error: {err}" self._log.error(msg) return False self._log.info("hash of library: {0}", output) def ipfs_import(self, lib, args): _hash = args[0] if len(args) > 1: lib_name = args[1] else: lib_name = _hash lib_root = os.path.dirname(lib.path) remote_libs = os.path.join(lib_root, b"remotes") if not os.path.exists(remote_libs): try: os.makedirs(remote_libs) except OSError as e: msg = f"Could not create {remote_libs}. Error: {e}" self._log.error(msg) return False path = os.path.join(remote_libs, lib_name.encode() + b".db") if not os.path.exists(path): cmd = f"ipfs get {_hash} -o".split() cmd.append(path) try: util.command_output(cmd) except (OSError, subprocess.CalledProcessError): self._log.error(f"Could not import {_hash}") return False # add all albums from remotes into a combined library jpath = os.path.join(remote_libs, b"joined.db") jlib = library.Library(jpath) nlib = library.Library(path) for album in nlib.albums(): if not self.already_added(album, jlib): new_album = [] for item in album.items(): item.id = None new_album.append(item) added_album = jlib.add_album(new_album) added_album.ipfs = album.ipfs added_album.store() def already_added(self, check, jlib): for jalbum in jlib.albums(): if jalbum.mb_albumid == check.mb_albumid: return True return False def ipfs_list(self, lib, args): fmt = config['format_album'].get() try: albums = self.query(lib, args) except OSError: ui.print_("No imported libraries yet.") return for album in albums: ui.print_(format(album, fmt), " : ", album.ipfs.decode()) def query(self, lib, args): rlib = self.get_remote_lib(lib) albums = rlib.albums(args) return albums def get_remote_lib(self, lib): lib_root = os.path.dirname(lib.path) remote_libs = os.path.join(lib_root, b"remotes") path = os.path.join(remote_libs, b"joined.db") if not os.path.isfile(path): raise OSError return library.Library(path) def ipfs_added_albums(self, rlib, tmpname): """ Returns a new library with only albums/items added to ipfs """ tmplib = library.Library(tmpname) for album in rlib.albums(): try: if album.ipfs: self.create_new_album(album, tmplib) except AttributeError: pass return tmplib def create_new_album(self, album, tmplib): items = [] for item in album.items(): try: if not item.ipfs: break except AttributeError: pass item_path = os.path.basename(item.path).decode( util._fsencoding(), 'ignore' ) # Clear current path from item item.path = f'/ipfs/{album.ipfs}/{item_path}' item.id = None items.append(item) if len(items) < 1: return False self._log.info("Adding '{0}' to temporary library", album) new_album = tmplib.add_album(items) new_album.ipfs = album.ipfs new_album.store()