diff --git a/lbry/lbry/extras/daemon/Daemon.py b/lbry/lbry/extras/daemon/Daemon.py index 2b433b4aa..58ee50920 100644 --- a/lbry/lbry/extras/daemon/Daemon.py +++ b/lbry/lbry/extras/daemon/Daemon.py @@ -4153,6 +4153,22 @@ class Daemon(metaclass=JSONRPCServerType): except (TypeError, ValueError): raise Exception("Invalid stream name.") + @staticmethod + # assume stream and collection have the same naming rules + def valid_collection_name_or_error(name: str): + try: + if not name: + raise Exception('Collection name cannot be blank.') + parsed = URL.parse(name) + if parsed.has_channel: + raise Exception( + "Collection names cannot start with '@' symbol. This is reserved for channels claims." + ) + if not parsed.has_stream or parsed.stream.name != name: + raise Exception('Collection name has invalid characters.') + except (TypeError, ValueError): + raise Exception("Invalid collection name.") + @staticmethod def valid_channel_name_or_error(name: str): try: diff --git a/lbry/lbry/extras/daemon/collection.py b/lbry/lbry/extras/daemon/collection.py new file mode 100644 index 000000000..ef487cb22 --- /dev/null +++ b/lbry/lbry/extras/daemon/collection.py @@ -0,0 +1,361 @@ +@requires(WALLET_COMPONENT, conditions=[WALLET_IS_UNLOCKED]) +async def jsonrpc_collection_create( + self, name, bid, allow_duplicate_name=False, + channel_id=None, channel_name=None, channel_account_id=None, + account_id=None, wallet_id=None, claim_address=None, funding_account_ids=None, + preview=False, blocking=False, **kwargs): + """ + Create a new collection .... + + Usage: + collection_create ( | --name=) ( | --bid=) + [--allow_duplicate_name=] + [--title=] [--description=<description>] + [--tags=<tags>...] [--languages=<languages>...] [--locations=<locations>...] + [--thumbnail_url=<thumbnail_url>] + [--account_id=<account_id>] [--wallet_id=<wallet_id>] + [--claim_address=<claim_address>] [--funding_account_ids=<funding_account_ids>...] + [--preview] [--blocking] + + Options: + --name=<name> : (str) name of the collection + --bid=<bid> : (decimal) amount to back the claim + --allow_duplicate_name=<allow_duplicate_name> : (bool) create new collection even if one already exists with + given name. default: false. + --claims=<claims> : (list) claim ids + --title=<title> : (str) title of the publication + --description=<description> : (str) description of the publication + --tags=<tags> : (list) content tags + --languages=<languages> : (list) languages used by the collection, + using RFC 5646 format, eg: + for English `--languages=en` + for Spanish (Spain) `--languages=es-ES` + for Spanish (Mexican) `--languages=es-MX` + for Chinese (Simplified) `--languages=zh-Hans` + for Chinese (Traditional) `--languages=zh-Hant` + --locations=<locations> : (list) locations of the collection, consisting of 2 letter + `country` code and a `state`, `city` and a postal + `code` along with a `latitude` and `longitude`. + for JSON RPC: pass a dictionary with aforementioned + attributes as keys, eg: + ... + "locations": [{'country': 'US', 'state': 'NH'}] + ... + for command line: pass a colon delimited list + with values in the following order: + + "COUNTRY:STATE:CITY:CODE:LATITUDE:LONGITUDE" + + making sure to include colon for blank values, for + example to provide only the city: + + ... --locations="::Manchester" + + with all values set: + + ... --locations="US:NH:Manchester:03101:42.990605:-71.460989" + + optionally, you can just pass the "LATITUDE:LONGITUDE": + + ... --locations="42.990605:-71.460989" + + finally, you can also pass JSON string of dictionary + on the command line as you would via JSON RPC + + ... --locations="{'country': 'US', 'state': 'NH'}" + + --thumbnail_url=<thumbnail_url>: (str) thumbnail url + # --cover_url=<cover_url> : (str) url of cover image + --account_id=<account_id> : (str) account to use for holding the transaction + --wallet_id=<wallet_id> : (str) restrict operation to specific wallet + --funding_account_ids=<funding_account_ids>: (list) ids of accounts to fund this transaction + --claim_address=<claim_address>: (str) address where the collection is sent to, if not specified + it will be determined automatically from the account + --preview : (bool) do not broadcast the transaction + --blocking : (bool) wait until transaction is in mempool + + Returns: {Transaction} + """ + wallet = self.wallet_manager.get_wallet_or_default(wallet_id) + account = wallet.get_account_or_default(account_id) + funding_accounts = wallet.get_accounts_or_all(funding_account_ids) + self.valid_collection_name_or_error(name) + channel = await self.get_channel_or_none(wallet, channel_account_id, channel_id, channel_name, for_signing=True) + amount = self.get_dewies_or_error('bid', bid, positive_value=True) + claim_address = await self.get_receiving_address(claim_address, account) + kwargs['fee_address'] = self.get_fee_address(kwargs, claim_address) + + existing_collections = await self.ledger.get_collections(accounts=wallet.accounts, claim_name=name) + if len(existing_collections) > 0: + if not allow_duplicate_name: + raise Exception( + f"You already have a collection under the name '{name}'. " + f"Use --allow-duplicate-name flag to override." + ) + + claim = Claim() + claim.collection.update(**kwargs) #maybe specify claims=[] + tx = await Transaction.claim_create( + name, claim, amount, claim_address, funding_accounts, funding_accounts[0], channel + ) + new_txo = tx.outputs[0] + + if channel: + new_txo.sign(channel) + await tx.sign(funding_accounts) + if not preview: + await self.broadcast_or_release(tx, blocking) + await self.storage.save_claims([self._old_get_temp_claim_info( + tx, new_txo, claim_address, claim, name, dewies_to_lbc(amount) + )]) + # await self.analytics_manager.send_new_channel() + else: + await account.ledger.release_tx(tx) + + return tx + +@requires(WALLET_COMPONENT, conditions=[WALLET_IS_UNLOCKED]) +async def jsonrpc_collection_update( + self, claim_id, bid=None, claim=None, allow_duplicate_name=False, + channel_id=None, channel_name=None, channel_account_id=None, clear_channel=False, + account_id=None, wallet_id=None, claim_address=None, funding_account_ids=None, + preview=False, blocking=False, replace=False, **kwargs): + """ + Update an existing collection claim. + + Usage: + collection_update (<claim_id> | --claim_id=<claim_id>) [<bid> | --bid=<bid>] + [--title=<title>] [--description=<description>] + [--tags=<tags>...] [--clear_tags] + [--languages=<languages>...] [--clear_languages] + [--locations=<locations>...] [--clear_locations] + [--thumbnail_url=<thumbnail_url>] [--cover_url=<cover_url>] + [--account_id=<account_id>] [--wallet_id=<wallet_id>] + [--claim_address=<claim_address>] [--new_signing_key] + [--funding_account_ids=<funding_account_ids>...] + [--preview] [--blocking] [--replace] + + Options: + --claim_id=<claim_id> : (str) claim_id of the collection to update + --bid=<bid> : (decimal) amount to back the claim + --title=<title> : (str) title of the publication + --description=<description> : (str) description of the publication + --tags=<tags> : (list) add content tags + --clear_tags : (bool) clear existing tags (prior to adding new ones) + --languages=<languages> : (list) languages used by the collection, + using RFC 5646 format, eg: + for English `--languages=en` + for Spanish (Spain) `--languages=es-ES` + for Spanish (Mexican) `--languages=es-MX` + for Chinese (Simplified) `--languages=zh-Hans` + for Chinese (Traditional) `--languages=zh-Hant` + --clear_languages : (bool) clear existing languages (prior to adding new ones) + --locations=<locations> : (list) locations of the collection, consisting of 2 letter + `country` code and a `state`, `city` and a postal + `code` along with a `latitude` and `longitude`. + for JSON RPC: pass a dictionary with aforementioned + attributes as keys, eg: + ... + "locations": [{'country': 'US', 'state': 'NH'}] + ... + for command line: pass a colon delimited list + with values in the following order: + + "COUNTRY:STATE:CITY:CODE:LATITUDE:LONGITUDE" + + making sure to include colon for blank values, for + example to provide only the city: + + ... --locations="::Manchester" + + with all values set: + + ... --locations="US:NH:Manchester:03101:42.990605:-71.460989" + + optionally, you can just pass the "LATITUDE:LONGITUDE": + + ... --locations="42.990605:-71.460989" + + finally, you can also pass JSON string of dictionary + on the command line as you would via JSON RPC + + ... --locations="{'country': 'US', 'state': 'NH'}" + + --clear_locations : (bool) clear existing locations (prior to adding new ones) + --thumbnail_url=<thumbnail_url>: (str) thumbnail url + #--cover_url=<cover_url> : (str) url of cover image + --account_id=<account_id> : (str) account in which to look for collection (default: all) + --wallet_id=<wallet_id> : (str) restrict operation to specific wallet + --funding_account_ids=<funding_account_ids>: (list) ids of accounts to fund this transaction + --claim_address=<claim_address>: (str) address where the collection is sent + --new_signing_key : (bool) generate a new signing key, will invalidate all previous publishes + --preview : (bool) do not broadcast the transaction + --blocking : (bool) wait until transaction is in mempool + --replace : (bool) instead of modifying specific values on + the collection, this will clear all existing values + and only save passed in values, useful for form + submissions where all values are always set + + Returns: {Transaction} + """ + wallet = self.wallet_manager.get_wallet_or_default(wallet_id) + funding_accounts = wallet.get_accounts_or_all(funding_account_ids) + if account_id: + account = wallet.get_account_or_error(account_id) + accounts = [account] + else: + account = wallet.default_account + accounts = wallet.accounts + + existing_collections = await self.ledger.get_collections( + wallet=wallet, accounts=accounts, claim_id=claim_id + ) + if len(existing_collections) != 1: + account_ids = ', '.join(f"'{account.id}'" for account in accounts) + raise Exception( + f"Can't find the collection '{claim_id}' in account(s) {account_ids}." + ) + # Here we might have a problem of replacing a stream with a collection + old_txo = existing_collections[0] + if not old_txo.claim.is_collection: #as we're only checking @ or not, this is not definitive + raise Exception( + f"A claim with id '{claim_id}' was found but it is not a stream or collection." + ) + + if bid is not None: + amount = self.get_dewies_or_error('bid', bid, positive_value=True) + else: + amount = old_txo.amount + + if claim_address is not None: + self.valid_address_or_error(claim_address) + else: + claim_address = old_txo.get_address(account.ledger) + + channel = None + if channel_id or channel_name: + channel = await self.get_channel_or_error( + wallet, channel_account_id, channel_id, channel_name, for_signing=True) + elif old_txo.claim.is_signed and not clear_channel and not replace: + channel = old_txo.channel + + fee_address = self.get_fee_address(kwargs, claim_address) + if fee_address: + kwargs['fee_address'] = fee_address + + if replace: + claim = Claim() + claim.channel.public_key_bytes = old_txo.claim.channel.public_key_bytes + else: + claim = Claim.from_bytes(old_txo.claim.to_bytes()) + claim.channel.update(**kwargs) + tx = await Transaction.claim_update( + old_txo, claim, amount, claim_address, funding_accounts, funding_accounts[0] + ) + new_txo = tx.outputs[0] + + if new_signing_key: + new_txo.generate_channel_private_key() + else: + new_txo.private_key = old_txo.private_key + + new_txo.script.generate() + + if not preview: + await tx.sign(funding_accounts) + account.add_channel_private_key(new_txo.private_key) + wallet.save() + await self.broadcast_or_release(tx, blocking) + await self.storage.save_claims([self._old_get_temp_claim_info( + tx, new_txo, claim_address, new_txo.claim, new_txo.claim_name, dewies_to_lbc(amount) + )]) + await self.analytics_manager.send_new_channel() + else: + await account.ledger.release_tx(tx) + + return tx + +@requires(WALLET_COMPONENT, conditions=[WALLET_IS_UNLOCKED]) +async def jsonrpc_collection_abandon( + self, claim_id=None, txid=None, nout=None, account_id=None, wallet_id=None, + preview=False, blocking=True): + """ + Abandon one of my collection claims. + + Usage: + collection_abandon [<claim_id> | --claim_id=<claim_id>] + [<txid> | --txid=<txid>] [<nout> | --nout=<nout>] + [--account_id=<account_id>] [--wallet_id=<wallet_id>] + [--preview] [--blocking] + + Options: + --claim_id=<claim_id> : (str) claim_id of the claim to abandon + --txid=<txid> : (str) txid of the claim to abandon + --nout=<nout> : (int) nout of the claim to abandon + --account_id=<account_id> : (str) id of the account to use + --wallet_id=<wallet_id> : (str) restrict operation to specific wallet + --preview : (bool) do not broadcast the transaction + --blocking : (bool) wait until abandon is in mempool + + Returns: {Transaction} + """ + wallet = self.wallet_manager.get_wallet_or_default(wallet_id) + if account_id: + account = wallet.get_account_or_error(account_id) + accounts = [account] + else: + account = wallet.default_account + accounts = wallet.accounts + + if txid is not None and nout is not None: + claims = await self.ledger.get_claims( + wallet=wallet, accounts=accounts, **{'txo.txid': txid, 'txo.position': nout} + ) + elif claim_id is not None: + claims = await self.ledger.get_claims( + wallet=wallet, accounts=accounts, claim_id=claim_id + ) + else: + raise Exception('Must specify claim_id, or txid and nout') + + if not claims: + raise Exception('No claim found for the specified claim_id or txid:nout') + + tx = await Transaction.create( + [Input.spend(txo) for txo in claims], [], [account], account + ) + + if not preview: + await self.broadcast_or_release(tx, blocking) + await self.analytics_manager.send_claim_action('abandon') + else: + await account.ledger.release_tx(tx) + + return tx + +@requires(WALLET_COMPONENT) +def jsonrpc_collection_list(self, account_id=None, wallet_id=None, page=None, page_size=None): + """ + List my collection claims. + + Usage: + collection_list [<account_id> | --account_id=<account_id>] [--wallet_id=<wallet_id>] + [--page=<page>] [--page_size=<page_size>] + + Options: + --account_id=<account_id> : (str) id of the account to use + --wallet_id=<wallet_id> : (str) restrict results to specific wallet + --page=<page> : (int) page to return during paginating + --page_size=<page_size> : (int) number of items on page during pagination + + Returns: {Paginated[Output]} + """ + wallet = self.wallet_manager.get_wallet_or_default(wallet_id) + if account_id: + account: LBCAccount = wallet.get_account_or_error(account_id) + collections = account.get_collections + collection_count = account.get_collection_count + else: + collections = partial(self.ledger.get_collections, wallet=wallet, accounts=wallet.accounts) + collection_count = partial(self.ledger.get_collection_count, wallet=wallet, accounts=wallet.accounts) + return maybe_paginate(collections, collection_count, page, page_size) diff --git a/lbry/lbry/testcase.py b/lbry/lbry/testcase.py index 361d37a9b..7b1fe95fb 100644 --- a/lbry/lbry/testcase.py +++ b/lbry/lbry/testcase.py @@ -268,6 +268,10 @@ class CommandTestCase(IntegrationTestCase): self.daemon.jsonrpc_channel_abandon(*args, **kwargs), confirm ) + # async def collection_create + # async def collection_update + # async def collection_abandon + async def support_create(self, claim_id, bid='1.0', confirm=True, **kwargs): return await self.confirm_and_render( self.daemon.jsonrpc_support_create(claim_id, bid, **kwargs), confirm diff --git a/lbry/lbry/wallet/constants.py b/lbry/lbry/wallet/constants.py index 75413ef22..0e1854e33 100644 --- a/lbry/lbry/wallet/constants.py +++ b/lbry/lbry/wallet/constants.py @@ -2,5 +2,6 @@ TXO_TYPES = { "stream": 1, "channel": 2, "support": 3, - "purchase": 4 + "purchase": 4, + "collection": 5 } diff --git a/lbry/lbry/wallet/database.py b/lbry/lbry/wallet/database.py index c78bcfa43..aee322cf1 100644 --- a/lbry/lbry/wallet/database.py +++ b/lbry/lbry/wallet/database.py @@ -144,6 +144,11 @@ class WalletDatabase(BaseDatabase): TXO_TYPES['stream'], TXO_TYPES['channel'] ] + def constrain_claims(constraints): + constraints['txo_type__in'] = [ + TXO_TYPES['stream'], TXO_TYPES['channel'] + ] + async def get_claims(self, **constraints) -> List[Output]: self.constrain_claims(constraints) return await self.get_utxos(**constraints) @@ -188,6 +193,18 @@ class WalletDatabase(BaseDatabase): self.constrain_supports(constraints) return self.get_utxo_count(**constraints) + @staticmethod + def constrain_collections(constraints): + constraints['txo_type'] = TXO_TYPES['collection'] + + def get_collections(self, **constraints): + self.constrain_supports(constraints) + return self.get_utxos(**constraints) + + def get_collection_count(self, **constraints): + self.constrain_supports(constraints) + return self.get_utxo_count(**constraints) + async def release_all_outputs(self, account): await self.db.execute_fetchall( "UPDATE txo SET is_reserved = 0 WHERE" diff --git a/lbry/lbry/wallet/ledger.py b/lbry/lbry/wallet/ledger.py index f5a20e5f3..2e96b7851 100644 --- a/lbry/lbry/wallet/ledger.py +++ b/lbry/lbry/wallet/ledger.py @@ -186,6 +186,12 @@ class MainNetLedger(BaseLedger): def get_channel_count(self, **constraints): return self.db.get_channel_count(**constraints) + def get_collections(self, **constraints): + return self.db.get_collections(**constraints) + + def get_collection_count(self, **constraints): + return self.db.get_collection_count(**constraints) + def get_supports(self, **constraints): return self.db.get_supports(**constraints) diff --git a/lbry/tests/integration/test_claim_commands.py b/lbry/tests/integration/test_claim_commands.py index 5558bc89e..b9497746e 100644 --- a/lbry/tests/integration/test_claim_commands.py +++ b/lbry/tests/integration/test_claim_commands.py @@ -16,7 +16,7 @@ log = logging.getLogger(__name__) class ClaimTestCase(CommandTestCase): - +# add collection tests files_directory = os.path.join(os.path.dirname(__file__), 'files') video_file_url = 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4' video_file_name = os.path.join(files_directory, 'ForBiggerEscapes.mp4')