exposed new protobuf fields in APIs and updated tests

This commit is contained in:
Lex Berezhny 2019-04-20 23:54:34 -04:00
parent 61cadb5cbe
commit 71f5061848
13 changed files with 964 additions and 824 deletions

2
.gitignore vendored
View file

@ -9,3 +9,5 @@
lbrynet.egg-info
__pycache__
_trial_temp/
/tests/integration/files

View file

@ -1622,16 +1622,16 @@ class Daemon(metaclass=JSONRPCServerType):
@requires(WALLET_COMPONENT, conditions=[WALLET_IS_UNLOCKED])
async def jsonrpc_channel_create(
self, name, bid, allow_duplicate_name=False, account_id=None, claim_address=None, preview=False, **kwargs):
self, name, bid, allow_duplicate_name=False, account_id=None, claim_address=None,
preview=False, **kwargs):
"""
Create a new channel by generating a channel private key and establishing an '@' prefixed claim.
Usage:
channel_create (<name> | --name=<name>) (<bid> | --bid=<bid>)
[--allow_duplicate_name=<allow_duplicate_name>]
[--title=<title>] [--description=<description>]
[--title=<title>] [--description=<description>] [--email=<email>] [--featured=<featured>...]
[--tags=<tags>...] [--languages=<languages>...] [--locations=<locations>...]
[--email=<email>]
[--website_url=<website_url>] [--thumbnail_url=<thumbnail_url>] [--cover_url=<cover_url>]
[--account_id=<account_id>] [--claim_address=<claim_address>] [--preview]
@ -1642,6 +1642,7 @@ class Daemon(metaclass=JSONRPCServerType):
--bid=<bid> : (decimal) amount to back the claim
--title=<title> : (str) title of the publication
--description=<description> : (str) description of the publication
--featured=<featured> : (list) claim_ids of featured content in channel
--tags=<tags> : (list) content tags
--languages=<languages> : (list) languages used by the channel,
using RFC 5646 format, eg:
@ -1737,6 +1738,7 @@ class Daemon(metaclass=JSONRPCServerType):
Usage:
channel_update (<claim_id> | --claim_id=<claim_id>) [<bid> | --bid=<bid>]
[--title=<title>] [--description=<description>]
[--featured=<featured>...] [--clear_featured]
[--tags=<tags>...] [--clear_tags]
[--languages=<languages>...] [--clear_languages]
[--locations=<locations>...] [--clear_locations]
@ -1749,6 +1751,8 @@ class Daemon(metaclass=JSONRPCServerType):
--bid=<bid> : (decimal) amount to back the claim
--title=<title> : (str) title of the publication
--description=<description> : (str) description of the publication
--clear_featured : (bool) clear existing featured content (prior to adding new ones)
--featured=<featured> : (list) claim_ids of featured content in channel
--clear_tags : (bool) clear existing tags (prior to adding new ones)
--tags=<tags> : (list) add content tags
--clear_languages : (bool) clear existing languages (prior to adding new ones)
@ -1825,9 +1829,10 @@ class Daemon(metaclass=JSONRPCServerType):
else:
claim_address = old_txo.get_address(account.ledger)
old_txo.claim.channel.update(**kwargs)
claim = Claim.from_bytes(old_txo.claim.to_bytes())
claim.channel.update(**kwargs)
tx = await Transaction.claim_update(
old_txo, amount, claim_address, [account], account
old_txo, claim, amount, claim_address, [account], account
)
new_txo = tx.outputs[0]
@ -1970,16 +1975,13 @@ class Daemon(metaclass=JSONRPCServerType):
Usage:
publish (<name> | --name=<name>) [--bid=<bid>] [--file_path=<file_path>]
[<stream_type> | --stream_type=<stream_type>]
[--tags=<tags>...] [--clear_tags]
[--languages=<languages>...] [--clear_languages]
[--locations=<locations>...] [--clear_locations]
[--fee_currency=<fee_currency>] [--fee_amount=<fee_amount>] [--fee_address=<fee_address>]
[--title=<title>] [--description=<description>] [--author=<author>] [--language=<language>]
[--license=<license>] [--license_url=<license_url>] [--thumbnail_url=<thumbnail_url>]
[--release_time=<release_time>]
[--video_width=<video_width>] [--video_height=<video_height>] [--video_duration=<video_duration>]
[--image_width=<image_width>] [--image_height=<image_height>] [--audio_duration=<audio_duration>]
[--release_time=<release_time>] [--width=<width>] [--height=<height>] [--duration=<duration>]
[--channel_id=<channel_id>] [--channel_name=<channel_name>]
[--channel_account_id=<channel_account_id>...]
[--account_id=<account_id>] [--claim_address=<claim_address>] [--preview]
@ -1988,7 +1990,6 @@ class Daemon(metaclass=JSONRPCServerType):
--name=<name> : (str) name of the content (can only consist of a-z A-Z 0-9 and -(dash))
--bid=<bid> : (decimal) amount to back the claim
--file_path=<file_path> : (str) path to file to be associated with name.
--stream_type=<stream_type> : (str) type of stream
--fee_currency=<fee_currency> : (string) specify fee currency
--fee_amount=<fee_amount> : (decimal) content download fee
--fee_address=<fee_address> : (str) address where to send fee payments, will use
@ -2045,17 +2046,10 @@ class Daemon(metaclass=JSONRPCServerType):
--license=<license> : (str) publication license
--license_url=<license_url> : (str) publication license url
--thumbnail_url=<thumbnail_url>: (str) thumbnail url
--release_time=<duration> : (int) original public release of content, seconds since UNIX epoch
--duration=<duration> : (int) audio/video duration in seconds, an attempt will be made to
calculate this automatically if not provided
--image_width=<image_width> : (int) image width
--image_height=<image_height> : (int) image height
--video_width=<video_width> : (int) video width
--video_height=<video_height> : (int) video height
--video_duration=<duration> : (int) video duration in seconds, an attempt will be made to
calculate this automatically if not provided
--audio_duration=<duration> : (int) audio duration in seconds, an attempt will be made to
calculate this automatically if not provided
--release_time=<release_time> : (int) original public release of content, seconds since UNIX epoch
--width=<width> : (int) image/video width, automatically calculated from media file
--height=<height> : (int) image/video height, automatically calculated from media file
--duration=<duration> : (int) audio/video duration in seconds, automatically calculated
--channel_id=<channel_id> : (str) claim id of the publisher channel
--channel_name=<channel_name> : (str) name of publisher channel
--channel_account_id=<channel_id>: (str) one or more account ids for accounts to look in
@ -2094,16 +2088,13 @@ class Daemon(metaclass=JSONRPCServerType):
Make a new stream claim and announce the associated file to lbrynet.
Usage:
stream_create (<name> | --name=<name>) (<bid> | --bid=<bid>)
(<file_path> | --file_path=<file_path>) [<stream_type> | --stream_type=<stream_type>]
stream_create (<name> | --name=<name>) (<bid> | --bid=<bid>) (<file_path> | --file_path=<file_path>)
[--allow_duplicate_name=<allow_duplicate_name>]
[--tags=<tags>...] [--languages=<languages>...] [--locations=<locations>...]
[--fee_currency=<fee_currency>] [--fee_amount=<fee_amount>] [--fee_address=<fee_address>]
[--title=<title>] [--description=<description>] [--author=<author>]
[--license=<license>] [--license_url=<license_url>] [--thumbnail_url=<thumbnail_url>]
[--release_time=<release_time>]
[--video_width=<video_width>] [--video_height=<video_height>] [--video_duration=<video_duration>]
[--image_width=<image_width>] [--image_height=<image_height>] [--audio_duration=<audio_duration>]
[--release_time=<release_time>] [--width=<width>] [--height=<height>] [--duration=<duration>]
[--channel_id=<channel_id>] [--channel_name=<channel_name>]
[--channel_account_id=<channel_account_id>...]
[--account_id=<account_id>] [--claim_address=<claim_address>] [--preview]
@ -2114,7 +2105,6 @@ class Daemon(metaclass=JSONRPCServerType):
given name. default: false.
--bid=<bid> : (decimal) amount to back the claim
--file_path=<file_path> : (str) path to file to be associated with name.
--stream_type=<stream_type> : (str) type of stream
--fee_currency=<fee_currency> : (string) specify fee currency
--fee_amount=<fee_amount> : (decimal) content download fee
--fee_address=<fee_address> : (str) address where to send fee payments, will use
@ -2168,17 +2158,10 @@ class Daemon(metaclass=JSONRPCServerType):
--license=<license> : (str) publication license
--license_url=<license_url> : (str) publication license url
--thumbnail_url=<thumbnail_url>: (str) thumbnail url
--release_time=<duration> : (int) original public release of content, seconds since UNIX epoch
--duration=<duration> : (int) audio/video duration in seconds, an attempt will be made to
calculate this automatically if not provided
--image_width=<image_width> : (int) image width
--image_height=<image_height> : (int) image height
--video_width=<video_width> : (int) video width
--video_height=<video_height> : (int) video height
--video_duration=<duration> : (int) video duration in seconds, an attempt will be made to
calculate this automatically if not provided
--audio_duration=<duration> : (int) audio duration in seconds, an attempt will be made to
calculate this automatically if not provided
--release_time=<release_time> : (int) original public release of content, seconds since UNIX epoch
--width=<width> : (int) image/video width, automatically calculated from media file
--height=<height> : (int) image/video height, automatically calculated from media file
--duration=<duration> : (int) audio/video duration in seconds, automatically calculated
--channel_id=<channel_id> : (str) claim id of the publisher channel
--channel_account_id=<channel_id>: (str) one or more account ids for accounts to look in
for channel certificates, defaults to all accounts.
@ -2249,9 +2232,7 @@ class Daemon(metaclass=JSONRPCServerType):
[--fee_currency=<fee_currency>] [--fee_amount=<fee_amount>] [--fee_address=<fee_address>]
[--title=<title>] [--description=<description>] [--author=<author>] [--language=<language>]
[--license=<license>] [--license_url=<license_url>] [--thumbnail_url=<thumbnail_url>]
[--release_time=<release_time>] [--stream_type=<stream_type>]
[--video_width=<video_width>] [--video_height=<video_height>] [--video_duration=<video_duration>]
[--image_width=<image_width>] [--image_height=<image_height>] [--audio_duration=<audio_duration>]
[--release_time=<release_time>] [--width=<width>] [--height=<height>] [--duration=<duration>]
[--channel_id=<channel_id>] [--channel_name=<channel_name>] [--clear_channel]
[--channel_account_id=<channel_account_id>...]
[--account_id=<account_id>] [--claim_address=<claim_address>] [--preview]
@ -2316,16 +2297,10 @@ class Daemon(metaclass=JSONRPCServerType):
--license=<license> : (str) publication license
--license_url=<license_url> : (str) publication license url
--thumbnail_url=<thumbnail_url>: (str) thumbnail url
--release_time=<duration> : (int) original public release of content, seconds since UNIX epoch
--stream_type=<stream_type> : (str) type of stream
--image_width=<image_width> : (int) image width
--image_height=<image_height> : (int) image height
--video_width=<video_width> : (int) video width
--video_height=<video_height> : (int) video height
--video_duration=<duration> : (int) video duration in seconds, an attempt will be made to
calculate this automatically if not provided
--audio_duration=<duration> : (int) audio duration in seconds, an attempt will be made to
calculate this automatically if not provided
--release_time=<release_time> : (int) original public release of content, seconds since UNIX epoch
--width=<width> : (int) image/video width, automatically calculated from media file
--height=<height> : (int) image/video height, automatically calculated from media file
--duration=<duration> : (int) audio/video duration in seconds, automatically calculated
--channel_id=<channel_id> : (str) claim id of the publisher channel
--clear_channel : (bool) remove channel signature
--channel_account_id=<channel_id>: (str) one or more account ids for accounts to look in
@ -2366,10 +2341,13 @@ class Daemon(metaclass=JSONRPCServerType):
elif old_txo.claim.is_signed and not clear_channel:
channel = old_txo.channel
kwargs['fee_address'] = self.get_fee_address(kwargs, claim_address)
old_txo.claim.stream.update(**kwargs)
if 'fee_address' in kwargs:
self.valid_address_or_error(kwargs['fee_address'])
claim = Claim.from_bytes(old_txo.claim.to_bytes())
claim.stream.update(file_path=file_path, **kwargs)
tx = await Transaction.claim_update(
old_txo, amount, claim_address, [account], account, channel
old_txo, claim, amount, claim_address, [account], account, channel
)
new_txo = tx.outputs[0]
@ -3314,7 +3292,8 @@ class Daemon(metaclass=JSONRPCServerType):
if 'fee_address' in kwargs:
self.valid_address_or_error(kwargs['fee_address'])
return kwargs['fee_address']
return claim_address
if 'fee_currency' in kwargs or 'fee_amount' in kwargs:
return claim_address
async def get_receiving_address(self, address: str, account: LBCAccount) -> str:
if address is None:

View file

@ -168,10 +168,7 @@ class JSONResponseEncoder(JSONEncoder):
})
if txo.script.is_claim_name or txo.script.is_update_claim:
output['value'] = txo.claim
if txo.claim.is_channel:
output['sub_type'] = 'channel'
elif txo.claim.is_stream:
output['sub_type'] = 'stream'
output['sub_type'] = txo.claim.claim_type
if txo.channel is not None:
output['signing_channel'] = {
'name': txo.channel.claim_name,
@ -210,8 +207,4 @@ class JSONResponseEncoder(JSONEncoder):
@staticmethod
def encode_claim(claim):
if claim.is_stream:
return claim.stream.to_dict()
elif claim.is_channel:
return claim.channel.to_dict()
return claim.to_dict()
return getattr(claim, claim.claim_type).to_dict()

512
lbrynet/schema/attrs.py Normal file
View file

@ -0,0 +1,512 @@
import json
import logging
import os.path
import hashlib
from typing import Tuple, List
from string import ascii_letters
from decimal import Decimal, ROUND_UP
from binascii import hexlify, unhexlify
from torba.client.hash import Base58
from torba.client.constants import COIN
from lbrynet.schema.mime_types import guess_media_type
from lbrynet.schema.base import Metadata, BaseMessageList
from lbrynet.schema.types.v2.claim_pb2 import (
Fee as FeeMessage,
Location as LocationMessage,
Language as LanguageMessage
)
log = logging.getLogger(__name__)
def calculate_sha256_file_hash(file_path):
sha256 = hashlib.sha256()
with open(file_path, 'rb') as f:
for chunk in iter(lambda: f.read(128 * sha256.block_size), b''):
sha256.update(chunk)
return sha256.digest()
class Dimmensional(Metadata):
__slots__ = ()
@property
def width(self) -> int:
return self.message.width
@width.setter
def width(self, width: int):
self.message.width = width
@property
def height(self) -> int:
return self.message.height
@height.setter
def height(self, height: int):
self.message.height = height
@property
def dimensions(self) -> Tuple[int, int]:
return self.width, self.height
@dimensions.setter
def dimensions(self, dimensions: Tuple[int, int]):
self.message.width, self.message.height = dimensions
def _extract(self, file_metadata, field):
try:
setattr(self, field, file_metadata.getValues(field)[0])
except:
log.exception(f'Could not extract {field} from file metadata.')
def update(self, file_metadata=None, height=None, width=None):
if height is not None:
self.height = height
elif file_metadata:
self._extract(file_metadata, 'height')
if width is not None:
self.width = width
elif file_metadata:
self._extract(file_metadata, 'width')
class Playable(Metadata):
__slots__ = ()
@property
def duration(self) -> int:
return self.message.duration
@duration.setter
def duration(self, duration: int):
self.message.duration = duration
def update(self, file_metadata=None, duration=None):
if duration is not None:
self.duration = duration
elif file_metadata:
try:
self.duration = file_metadata.getValues('duration')[0].seconds
except:
log.exception('Could not extract duration from file metadata.')
class Image(Dimmensional):
__slots__ = ()
class Audio(Playable):
__slots__ = ()
class Video(Dimmensional, Playable):
__slots__ = ()
def update(self, file_metadata=None, height=None, width=None, duration=None):
Dimmensional.update(self, file_metadata, height, width)
Playable.update(self, file_metadata, duration)
class Source(Metadata):
__slots__ = ()
def update(self, file_path=None):
if file_path is not None:
self.name = os.path.basename(file_path)
self.media_type, stream_type = guess_media_type(file_path)
if not os.path.isfile(file_path):
raise Exception(f"File does not exist: {file_path}")
self.size = os.path.getsize(file_path)
if self.size == 0:
raise Exception(f"Cannot publish empty file: {file_path}")
self.file_hash_bytes = calculate_sha256_file_hash(file_path)
return stream_type
@property
def name(self) -> str:
return self.message.name
@name.setter
def name(self, name: str):
self.message.name = name
@property
def size(self) -> int:
return self.message.size
@size.setter
def size(self, size: int):
self.message.size = size
@property
def media_type(self) -> str:
return self.message.media_type
@media_type.setter
def media_type(self, media_type: str):
self.message.media_type = media_type
@property
def file_hash(self) -> str:
return hexlify(self.message.hash).decode()
@file_hash.setter
def file_hash(self, file_hash: str):
self.message.hash = unhexlify(file_hash.encode())
@property
def file_hash_bytes(self) -> bytes:
return self.message.hash
@file_hash_bytes.setter
def file_hash_bytes(self, file_hash_bytes: bytes):
self.message.hash = file_hash_bytes
@property
def sd_hash(self) -> str:
return hexlify(self.message.sd_hash).decode()
@sd_hash.setter
def sd_hash(self, sd_hash: str):
self.message.sd_hash = unhexlify(sd_hash.encode())
@property
def sd_hash_bytes(self) -> bytes:
return self.message.sd_hash
@sd_hash_bytes.setter
def sd_hash_bytes(self, sd_hash: bytes):
self.message.sd_hash = sd_hash
@property
def url(self) -> str:
return self.message.url
@url.setter
def url(self, url: str):
self.message.url = url
class Fee(Metadata):
__slots__ = ()
def update(self, address: str = None, currency: str = None, amount=None):
if address is not None:
self.address = address
if currency is not None and amount is not None:
currency = currency.lower()
assert currency in ('lbc', 'btc', 'usd'), f'Unknown currency type: {currency}'
setattr(self, currency, Decimal(amount))
@property
def currency(self) -> str:
return FeeMessage.Currency.Name(self.message.currency)
@property
def address(self) -> str:
return Base58.encode(self.message.address)
@address.setter
def address(self, address: str):
self.message.address = Base58.decode(address)
@property
def address_bytes(self) -> bytes:
return self.message.address
@address_bytes.setter
def address_bytes(self, address: bytes):
self.message.address = address
@property
def amount(self) -> Decimal:
if self.currency == 'LBC':
return self.lbc
if self.currency == 'BTC':
return self.btc
if self.currency == 'USD':
return self.usd
DEWIES = Decimal(COIN)
@property
def lbc(self) -> Decimal:
if self.message.currency != FeeMessage.LBC:
raise ValueError('LBC can only be returned for LBC fees.')
return Decimal(self.message.amount / self.DEWIES)
@lbc.setter
def lbc(self, amount: Decimal):
self.dewies = int(amount * self.DEWIES)
@property
def dewies(self) -> int:
if self.message.currency != FeeMessage.LBC:
raise ValueError('Dewies can only be returned for LBC fees.')
return self.message.amount
@dewies.setter
def dewies(self, amount: int):
self.message.amount = amount
self.message.currency = FeeMessage.LBC
SATOSHIES = Decimal(COIN)
@property
def btc(self) -> Decimal:
if self.message.currency != FeeMessage.BTC:
raise ValueError('BTC can only be returned for BTC fees.')
return Decimal(self.message.amount / self.SATOSHIES)
@btc.setter
def btc(self, amount: Decimal):
self.satoshis = int(amount * self.SATOSHIES)
@property
def satoshis(self) -> int:
if self.message.currency != FeeMessage.BTC:
raise ValueError('Satoshies can only be returned for BTC fees.')
return self.message.amount
@satoshis.setter
def satoshis(self, amount: int):
self.message.amount = amount
self.message.currency = FeeMessage.BTC
PENNIES = Decimal('100.0')
PENNY = Decimal('0.01')
@property
def usd(self) -> Decimal:
if self.message.currency != FeeMessage.USD:
raise ValueError('USD can only be returned for USD fees.')
return Decimal(self.message.amount / self.PENNIES)
@usd.setter
def usd(self, amount: Decimal):
self.pennies = int(amount.quantize(self.PENNY, ROUND_UP) * self.PENNIES)
@property
def pennies(self) -> int:
if self.message.currency != FeeMessage.USD:
raise ValueError('Pennies can only be returned for USD fees.')
return self.message.amount
@pennies.setter
def pennies(self, amount: int):
self.message.amount = amount
self.message.currency = FeeMessage.USD
class ClaimReference(Metadata):
__slots__ = ()
@property
def claim_id(self) -> str:
return hexlify(self.claim_hash[::-1]).decode()
@claim_id.setter
def claim_id(self, claim_id: str):
self.claim_hash = unhexlify(claim_id)[::-1]
@property
def claim_hash(self) -> bytes:
return self.message.claim_hash
@claim_hash.setter
def claim_hash(self, claim_hash: bytes):
self.message.claim_hash = claim_hash
class ClaimList(BaseMessageList[ClaimReference]):
__slots__ = ()
item_class = ClaimReference
@property
def _message(self):
return self.message.claim_references
def append(self, value):
self.add().claim_id = value
@property
def ids(self) -> List[str]:
return [c.claim_id for c in self]
class Language(Metadata):
__slots__ = ()
@property
def langtag(self) -> str:
langtag = []
if self.language:
langtag.append(self.language)
if self.script:
langtag.append(self.script)
if self.region:
langtag.append(self.region)
return '-'.join(langtag)
@langtag.setter
def langtag(self, langtag: str):
parts = langtag.split('-')
self.language = parts.pop(0)
if parts and len(parts[0]) == 4:
self.script = parts.pop(0)
if parts and len(parts[0]) == 2:
self.region = parts.pop(0)
assert not parts, f"Failed to parse language tag: {langtag}"
@property
def language(self) -> str:
if self.message.language:
return LanguageMessage.Language.Name(self.message.language)
@language.setter
def language(self, language: str):
self.message.language = LanguageMessage.Language.Value(language)
@property
def script(self) -> str:
if self.message.script:
return LanguageMessage.Script.Name(self.message.script)
@script.setter
def script(self, script: str):
self.message.script = LanguageMessage.Script.Value(script)
@property
def region(self) -> str:
if self.message.region:
return LocationMessage.Country.Name(self.message.region)
@region.setter
def region(self, region: str):
self.message.region = LocationMessage.Country.Value(region)
class LanguageList(BaseMessageList[Language]):
__slots__ = ()
item_class = Language
def append(self, value: str):
self.add().langtag = value
class Location(Metadata):
__slots__ = ()
def from_value(self, value):
if isinstance(value, str) and value.startswith('{'):
value = json.loads(value)
if isinstance(value, dict):
for key, val in value.items():
setattr(self, key, val)
elif isinstance(value, str):
parts = value.split(':')
if len(parts) > 2 or (parts[0] and parts[0][0] in ascii_letters):
country = parts and parts.pop(0)
if country:
self.country = country
state = parts and parts.pop(0)
if state:
self.state = state
city = parts and parts.pop(0)
if city:
self.city = city
code = parts and parts.pop(0)
if code:
self.code = code
latitude = parts and parts.pop(0)
if latitude:
self.latitude = latitude
longitude = parts and parts.pop(0)
if longitude:
self.longitude = longitude
else:
raise ValueError(f'Could not parse country value: {value}')
@property
def country(self) -> str:
if self.message.country:
return LocationMessage.Country.Name(self.message.country)
@country.setter
def country(self, country: str):
self.message.country = LocationMessage.Country.Value(country)
@property
def state(self) -> str:
return self.message.state
@state.setter
def state(self, state: str):
self.message.state = state
@property
def city(self) -> str:
return self.message.city
@city.setter
def city(self, city: str):
self.message.city = city
@property
def code(self) -> str:
return self.message.code
@code.setter
def code(self, code: str):
self.message.code = code
GPS_PRECISION = Decimal('10000000')
@property
def latitude(self) -> str:
if self.message.latitude:
return str(Decimal(self.message.latitude) / self.GPS_PRECISION)
@latitude.setter
def latitude(self, latitude: str):
latitude = Decimal(latitude)
assert -90 <= latitude <= 90, "Latitude must be between -90 and 90 degrees."
self.message.latitude = int(latitude * self.GPS_PRECISION)
@property
def longitude(self) -> str:
if self.message.longitude:
return str(Decimal(self.message.longitude) / self.GPS_PRECISION)
@longitude.setter
def longitude(self, longitude: str):
longitude = Decimal(longitude)
assert -180 <= longitude <= 180, "Longitude must be between -180 and 180 degrees."
self.message.longitude = int(longitude * self.GPS_PRECISION)
class LocationList(BaseMessageList[Location]):
__slots__ = ()
item_class = Location
def append(self, value):
self.add().from_value(value)

View file

@ -1,4 +1,5 @@
from binascii import hexlify, unhexlify
from typing import List, Iterator, TypeVar, Generic
from google.protobuf.message import DecodeError
from google.protobuf.json_format import MessageToDict
@ -21,9 +22,10 @@ class Signable:
self.unsigned_payload = None
self.signing_channel_hash = None
@property
def is_undetermined(self):
return self.message.WhichOneof('type') is None
def clear_signature(self):
self.signature = None
self.unsigned_payload = None
self.signing_channel_hash = None
@property
def signing_channel_id(self):
@ -72,3 +74,48 @@ class Signable:
def __bytes__(self):
return self.to_bytes()
class Metadata:
__slots__ = 'message',
def __init__(self, message):
self.message = message
I = TypeVar('I')
class BaseMessageList(Metadata, Generic[I]):
__slots__ = ()
item_class = None
@property
def _message(self):
return self.message
def add(self) -> I:
return self.item_class(self._message.add())
def extend(self, values: List[str]):
for value in values:
self.append(value)
def append(self, value: str):
raise NotImplemented
def __len__(self):
return len(self._message)
def __iter__(self) -> Iterator[I]:
for item in self._message:
yield self.item_class(item)
def __getitem__(self, item) -> I:
return self.item_class(self._message[item])
def __delitem__(self, key):
del self._message[key]

View file

@ -1,76 +1,85 @@
import os.path
import json
from string import ascii_letters
from typing import List, Tuple, Iterator, TypeVar, Generic
from decimal import Decimal, ROUND_UP
import logging
from typing import List
from binascii import hexlify, unhexlify
from google.protobuf.json_format import MessageToDict
from google.protobuf.message import DecodeError
from hachoir.core.log import log as hachoir_log
from hachoir.parser import createParser as binary_file_parser
from hachoir.metadata import extractMetadata as binary_file_metadata
from hachoir.core.log import log as hachoir_log
from torba.client.hash import Base58
from torba.client.constants import COIN
from lbrynet.schema import compat
from lbrynet.schema.base import Signable
from lbrynet.schema.mime_types import guess_media_type
from lbrynet.schema.types.v2.claim_pb2 import (
Claim as ClaimMessage,
Fee as FeeMessage,
Location as LocationMessage,
Language as LanguageMessage
from lbrynet.schema.mime_types import guess_media_type, guess_stream_type
from lbrynet.schema.attrs import (
Source, Playable, Dimmensional, Fee, Image, Video, Audio,
LanguageList, LocationList, ClaimList, ClaimReference
)
from lbrynet.schema.types.v2.claim_pb2 import Claim as ClaimMessage
hachoir_log.use_print = False
log = logging.getLogger(__name__)
class Claim(Signable):
STREAM = 'stream'
CHANNEL = 'channel'
COLLECTION = 'collection'
REPOST = 'repost'
__slots__ = 'version',
message_class = ClaimMessage
def __init__(self, claim_message=None):
super().__init__(claim_message)
def __init__(self, message=None):
super().__init__(message)
self.version = 2
@property
def claim_type(self) -> str:
return self.message.WhichOneof('type')
def get_message(self, type_name):
message = getattr(self.message, type_name)
if self.claim_type is None:
message.SetInParent()
if self.claim_type != type_name:
raise ValueError(f'Claim is not a {type_name}.')
return message
@property
def is_stream(self):
return self.message.WhichOneof('type') == 'stream'
@property
def is_channel(self):
return self.message.WhichOneof('type') == 'channel'
@property
def stream_message(self):
if self.is_undetermined:
self.message.stream.SetInParent()
if not self.is_stream:
raise ValueError('Claim is not a stream.')
return self.message.stream
return self.claim_type == self.STREAM
@property
def stream(self) -> 'Stream':
return Stream(self)
@property
def channel_message(self):
if self.is_undetermined:
self.message.channel.SetInParent()
if not self.is_channel:
raise ValueError('Claim is not a channel.')
return self.message.channel
def is_channel(self):
return self.claim_type == self.CHANNEL
@property
def channel(self) -> 'Channel':
return Channel(self)
def to_dict(self):
return MessageToDict(self.message, preserving_proto_field_name=True)
@property
def is_repost(self):
return self.claim_type == self.REPOST
@property
def repost(self) -> 'Repost':
return Repost(self)
@property
def is_collection(self):
return self.claim_type == self.COLLECTION
@property
def collection(self) -> 'Collection':
return Collection(self)
@classmethod
def from_bytes(cls, data: bytes) -> 'Claim':
@ -89,494 +98,21 @@ class Claim(Signable):
return claim
I = TypeVar('I')
class BaseMessageList(Generic[I]):
__slots__ = 'message',
item_class = None
def __init__(self, message):
self.message = message
def add(self) -> I:
return self.item_class(self.message.add())
def extend(self, values: List[str]):
for value in values:
self.append(value)
def append(self, value: str):
raise NotImplemented
def __len__(self):
return len(self.message)
def __iter__(self) -> Iterator[I]:
for lang in self.message:
yield self.item_class(lang)
def __getitem__(self, item) -> I:
return self.item_class(self.message[item])
class Dimmensional:
__slots__ = ()
@property
def width(self) -> int:
return self.message.width
@width.setter
def width(self, width: int):
self.message.width = width
@property
def height(self) -> int:
return self.message.height
@height.setter
def height(self, height: int):
self.message.height = height
@property
def dimensions(self) -> Tuple[int, int]:
return self.width, self.height
@dimensions.setter
def dimensions(self, dimensions: Tuple[int, int]):
self.message.width, self.message.height = dimensions
class Playable:
__slots__ = ()
@property
def duration(self) -> int:
return self.message.duration
@duration.setter
def duration(self, duration: int):
self.message.duration = duration
def set_duration_from_path(self, file_path):
try:
file_metadata = binary_file_metadata(binary_file_parser(file_path))
self.duration = file_metadata.getValues('duration')[0].seconds
except:
pass
class Image(Dimmensional):
__slots__ = 'message',
def __init__(self, image_message):
self.message = image_message
class Video(Dimmensional, Playable):
__slots__ = 'message',
def __init__(self, video_message):
self.message = video_message
class Audio(Playable):
__slots__ = 'message',
def __init__(self, audio_message):
self.message = audio_message
class Source:
__slots__ = 'message',
def __init__(self, file_message):
self.message = file_message
@property
def name(self) -> str:
return self.message.name
@name.setter
def name(self, name: str):
self.message.name = name
@property
def size(self) -> int:
return self.message.size
@size.setter
def size(self, size: int):
self.message.size = size
@property
def media_type(self) -> str:
return self.message.media_type
@media_type.setter
def media_type(self, media_type: str):
self.message.media_type = media_type
@property
def sd_hash(self) -> str:
return hexlify(self.message.sd_hash).decode()
@sd_hash.setter
def sd_hash(self, sd_hash: str):
self.message.sd_hash = unhexlify(sd_hash.encode())
@property
def sd_hash_bytes(self) -> bytes:
return self.message.sd_hash
@sd_hash_bytes.setter
def sd_hash_bytes(self, sd_hash: bytes):
self.message.sd_hash = sd_hash
@property
def url(self) -> str:
return self.message.url
@url.setter
def url(self, url: str):
self.message.url = url
class Fee:
__slots__ = '_fee',
def __init__(self, fee_message):
self._fee = fee_message
@property
def currency(self) -> str:
return FeeMessage.Currency.Name(self._fee.currency)
@property
def address(self) -> str:
return Base58.encode(self._fee.address)
@address.setter
def address(self, address: str):
self._fee.address = Base58.decode(address)
@property
def address_bytes(self) -> bytes:
return self._fee.address
@address_bytes.setter
def address_bytes(self, address: bytes):
self._fee.address = address
@property
def amount(self) -> Decimal:
if self.currency == 'LBC':
return self.lbc
if self.currency == 'BTC':
return self.btc
if self.currency == 'USD':
return self.usd
DEWIES = Decimal(COIN)
@property
def lbc(self) -> Decimal:
if self._fee.currency != FeeMessage.LBC:
raise ValueError('LBC can only be returned for LBC fees.')
return Decimal(self._fee.amount / self.DEWIES)
@lbc.setter
def lbc(self, amount: Decimal):
self.dewies = int(amount * self.DEWIES)
@property
def dewies(self) -> int:
if self._fee.currency != FeeMessage.LBC:
raise ValueError('Dewies can only be returned for LBC fees.')
return self._fee.amount
@dewies.setter
def dewies(self, amount: int):
self._fee.amount = amount
self._fee.currency = FeeMessage.LBC
SATOSHIES = Decimal(COIN)
@property
def btc(self) -> Decimal:
if self._fee.currency != FeeMessage.BTC:
raise ValueError('BTC can only be returned for BTC fees.')
return Decimal(self._fee.amount / self.SATOSHIES)
@btc.setter
def btc(self, amount: Decimal):
self.satoshis = int(amount * self.SATOSHIES)
@property
def satoshis(self) -> int:
if self._fee.currency != FeeMessage.BTC:
raise ValueError('Satoshies can only be returned for BTC fees.')
return self._fee.amount
@satoshis.setter
def satoshis(self, amount: int):
self._fee.amount = amount
self._fee.currency = FeeMessage.BTC
PENNIES = Decimal('100.0')
PENNY = Decimal('0.01')
@property
def usd(self) -> Decimal:
if self._fee.currency != FeeMessage.USD:
raise ValueError('USD can only be returned for USD fees.')
return Decimal(self._fee.amount / self.PENNIES)
@usd.setter
def usd(self, amount: Decimal):
self.pennies = int(amount.quantize(self.PENNY, ROUND_UP) * self.PENNIES)
@property
def pennies(self) -> int:
if self._fee.currency != FeeMessage.USD:
raise ValueError('Pennies can only be returned for USD fees.')
return self._fee.amount
@pennies.setter
def pennies(self, amount: int):
self._fee.amount = amount
self._fee.currency = FeeMessage.USD
class ClaimReference:
__slots__ = 'message',
def __init__(self, message):
self.message = message
@property
def claim_id(self) -> str:
return hexlify(self.claim_hash[::-1]).decode()
@claim_id.setter
def claim_id(self, claim_id: str):
self.claim_hash = unhexlify(claim_id)[::-1]
@property
def claim_hash(self) -> bytes:
return self.message.claim_hash
@claim_hash.setter
def claim_hash(self, claim_hash: bytes):
self.message.claim_hash = claim_hash
class ClaimList(BaseMessageList[ClaimReference]):
__slots__ = ()
item_class = ClaimReference
def append(self, value):
self.add().claim_id = value
@property
def claim_ids(self) -> List[str]:
return [c.claim_id for c in self]
class Language:
__slots__ = 'message',
def __init__(self, message):
self.message = message
@property
def langtag(self) -> str:
langtag = []
if self.language:
langtag.append(self.language)
if self.script:
langtag.append(self.script)
if self.region:
langtag.append(self.region)
return '-'.join(langtag)
@langtag.setter
def langtag(self, langtag: str):
parts = langtag.split('-')
self.language = parts.pop(0)
if parts and len(parts[0]) == 4:
self.script = parts.pop(0)
if parts and len(parts[0]) == 2:
self.region = parts.pop(0)
assert not parts, f"Failed to parse language tag: {langtag}"
@property
def language(self) -> str:
if self.message.language:
return LanguageMessage.Language.Name(self.message.language)
@language.setter
def language(self, language: str):
self.message.language = LanguageMessage.Language.Value(language)
@property
def script(self) -> str:
if self.message.script:
return LanguageMessage.Script.Name(self.message.script)
@script.setter
def script(self, script: str):
self.message.script = LanguageMessage.Script.Value(script)
@property
def region(self) -> str:
if self.message.region:
return LocationMessage.Country.Name(self.message.region)
@region.setter
def region(self, region: str):
self.message.region = LocationMessage.Country.Value(region)
class LanguageList(BaseMessageList[Language]):
__slots__ = ()
item_class = Language
def append(self, value: str):
self.add().langtag = value
class Location:
__slots__ = 'message',
def __init__(self, message):
self.message = message
def from_value(self, value):
if isinstance(value, str) and value.startswith('{'):
value = json.loads(value)
if isinstance(value, dict):
for key, val in value.items():
setattr(self, key, val)
elif isinstance(value, str):
parts = value.split(':')
if len(parts) > 2 or (parts[0] and parts[0][0] in ascii_letters):
country = parts and parts.pop(0)
if country:
self.country = country
state = parts and parts.pop(0)
if state:
self.state = state
city = parts and parts.pop(0)
if city:
self.city = city
code = parts and parts.pop(0)
if code:
self.code = code
latitude = parts and parts.pop(0)
if latitude:
self.latitude = latitude
longitude = parts and parts.pop(0)
if longitude:
self.longitude = longitude
else:
raise ValueError(f'Could not parse country value: {value}')
@property
def country(self) -> str:
if self.message.country:
return LocationMessage.Country.Name(self.message.country)
@country.setter
def country(self, country: str):
self.message.country = LocationMessage.Country.Value(country)
@property
def state(self) -> str:
return self.message.state
@state.setter
def state(self, state: str):
self.message.state = state
@property
def city(self) -> str:
return self.message.city
@city.setter
def city(self, city: str):
self.message.city = city
@property
def code(self) -> str:
return self.message.code
@code.setter
def code(self, code: str):
self.message.code = code
GPS_PRECISION = Decimal('10000000')
@property
def latitude(self) -> str:
if self.message.latitude:
return str(Decimal(self.message.latitude) / self.GPS_PRECISION)
@latitude.setter
def latitude(self, latitude: str):
latitude = Decimal(latitude)
assert -90 <= latitude <= 90, "Latitude must be between -90 and 90 degrees."
self.message.latitude = int(latitude * self.GPS_PRECISION)
@property
def longitude(self) -> str:
if self.message.longitude:
return str(Decimal(self.message.longitude) / self.GPS_PRECISION)
@longitude.setter
def longitude(self, longitude: str):
longitude = Decimal(longitude)
assert -180 <= longitude <= 180, "Longitude must be between -180 and 180 degrees."
self.message.longitude = int(longitude * self.GPS_PRECISION)
class LocationList(BaseMessageList[Location]):
__slots__ = ()
item_class = Location
def append(self, value):
self.add().from_value(value)
class BaseClaimSubType:
class BaseClaim:
__slots__ = 'claim', 'message'
claim_type = None
object_fields = 'thumbnail',
repeat_fields = 'tags', 'languages', 'locations'
def __init__(self, claim: Claim):
def __init__(self, claim: Claim = None):
self.claim = claim or Claim()
self.message = self.claim.get_message(self.claim_type)
def to_dict(self):
claim = self.claim.to_dict()
claim = MessageToDict(self.claim.message, preserving_proto_field_name=True)
claim.update(claim.pop(self.claim_type))
if 'languages' in claim:
claim['languages'] = self.langtags
return claim
@ -642,78 +178,19 @@ class BaseClaimSubType:
return LocationList(self.claim.message.locations)
class Channel(BaseClaimSubType):
class Stream(BaseClaim):
__slots__ = ()
object_fields = BaseClaimSubType.object_fields + ('cover',)
repeat_fields = BaseClaimSubType.repeat_fields + ('featured',)
claim_type = Claim.STREAM
def __init__(self, claim: Claim = None):
super().__init__(claim)
self.message = self.claim.channel_message
object_fields = BaseClaim.object_fields + ('source',)
def to_dict(self):
claim = super().to_dict()
claim.update(claim.pop('channel'))
claim['public_key'] = self.public_key
return claim
@property
def public_key(self) -> str:
return hexlify(self.message.public_key).decode()
@public_key.setter
def public_key(self, sd_public_key: str):
self.message.public_key = unhexlify(sd_public_key.encode())
@property
def public_key_bytes(self) -> bytes:
return self.message.public_key
@public_key_bytes.setter
def public_key_bytes(self, public_key: bytes):
self.message.public_key = public_key
@property
def email(self) -> str:
return self.message.email
@email.setter
def email(self, email: str):
self.message.email = email
@property
def website_url(self) -> str:
return self.message.website_url
@website_url.setter
def website_url(self, website_url: str):
self.message.website_url = website_url
@property
def cover(self) -> Source:
return Source(self.message.cover)
@property
def featured(self) -> ClaimList:
return ClaimList(self.message.featured)
class Stream(BaseClaimSubType):
__slots__ = ()
object_fields = BaseClaimSubType.object_fields + ('source',)
def __init__(self, claim: Claim = None):
super().__init__(claim)
self.message = self.claim.stream_message
def to_dict(self):
claim = super().to_dict()
claim.update(claim.pop('stream'))
if 'source' in claim:
if 'hash' in claim['source']:
claim['source']['hash'] = self.source.file_hash
if 'sd_hash' in claim['source']:
claim['source']['sd_hash'] = self.source.sd_hash
fee = claim.get('fee', {})
@ -723,58 +200,39 @@ class Stream(BaseClaimSubType):
fee['amount'] = self.fee.amount
return claim
def update(
self, file_path=None, stream_type=None,
fee_currency=None, fee_amount=None, fee_address=None,
**kwargs):
duration_was_not_set = True
sub_types = ('image', 'video', 'audio')
for key in list(kwargs.keys()):
for sub_type in sub_types:
if key.startswith(f'{sub_type}_'):
stream_type = sub_type
sub_obj = getattr(self, sub_type)
sub_obj_attr = key[len(f'{sub_type}_'):]
setattr(sub_obj, sub_obj_attr, kwargs.pop(key))
if sub_obj_attr == 'duration':
duration_was_not_set = False
break
if stream_type is not None:
if stream_type not in sub_types:
raise Exception(
f"stream_type of '{stream_type}' is not valid, must be one of: {sub_types}"
)
sub_obj = getattr(self, stream_type)
if duration_was_not_set and file_path and isinstance(sub_obj, Playable):
sub_obj.set_duration_from_path(file_path)
def update(self, file_path=None, height=None, width=None, duration=None, **kwargs):
self.fee.update(
kwargs.pop('fee_address', None),
kwargs.pop('fee_currency', None),
kwargs.pop('fee_amount', None)
)
if 'sd_hash' in kwargs:
self.source.sd_hash = kwargs.pop('sd_hash')
super().update(**kwargs)
stream_type = None
if file_path is not None:
self.source.media_type = guess_media_type(file_path)
if not os.path.isfile(file_path):
raise Exception(f"File does not exist: {file_path}")
self.source.size = os.path.getsize(file_path)
if self.source.size == 0:
raise Exception(f"Cannot publish empty file: {file_path}")
stream_type = self.source.update(file_path=file_path)
elif self.source.name:
self.source.media_type, stream_type = guess_media_type(self.source.name)
elif self.source.media_type:
stream_type = guess_stream_type(self.source.media_type)
if fee_amount and fee_currency:
if fee_address:
self.fee.address = fee_address
if fee_currency.lower() == 'lbc':
self.fee.lbc = Decimal(fee_amount)
elif fee_currency.lower() == 'btc':
self.fee.btc = Decimal(fee_amount)
elif fee_currency.lower() == 'usd':
self.fee.usd = Decimal(fee_amount)
else:
raise Exception(f'Unknown currency type: {fee_currency}')
if stream_type in ('image', 'video', 'audio'):
media = getattr(self, stream_type)
media_args = {'file_metadata': None}
try:
media_args['file_metadata'] = binary_file_metadata(binary_file_parser(file_path))
except:
log.exception('Could not read file metadata.')
if isinstance(media, Playable):
media_args['duration'] = duration
if isinstance(media, Dimmensional):
media_args['height'] = height
media_args['width'] = width
media.update(**media_args)
super().update(**kwargs)
@property
def author(self) -> str:
@ -831,3 +289,90 @@ class Stream(BaseClaimSubType):
@property
def audio(self) -> Audio:
return Audio(self.message.audio)
class Channel(BaseClaim):
__slots__ = ()
claim_type = Claim.CHANNEL
object_fields = BaseClaim.object_fields + ('cover',)
repeat_fields = BaseClaim.repeat_fields + ('featured',)
def to_dict(self):
claim = super().to_dict()
claim['public_key'] = self.public_key
if 'featured' in claim:
claim['featured'] = self.featured.ids
return claim
@property
def public_key(self) -> str:
return hexlify(self.message.public_key).decode()
@public_key.setter
def public_key(self, sd_public_key: str):
self.message.public_key = unhexlify(sd_public_key.encode())
@property
def public_key_bytes(self) -> bytes:
return self.message.public_key
@public_key_bytes.setter
def public_key_bytes(self, public_key: bytes):
self.message.public_key = public_key
@property
def email(self) -> str:
return self.message.email
@email.setter
def email(self, email: str):
self.message.email = email
@property
def website_url(self) -> str:
return self.message.website_url
@website_url.setter
def website_url(self, website_url: str):
self.message.website_url = website_url
@property
def cover(self) -> Source:
return Source(self.message.cover)
@property
def featured(self) -> ClaimList:
return ClaimList(self.message.featured)
class Repost(BaseClaim):
__slots__ = ()
claim_type = Claim.REPOST
@property
def reference(self) -> ClaimReference:
return ClaimReference(self.message)
class Collection(BaseClaim):
__slots__ = ()
claim_type = Claim.COLLECTION
repeat_fields = BaseClaim.repeat_fields + ('claims',)
def to_dict(self):
claim = super().to_dict()
if 'claim_references' in claim:
claim['claim_references'] = self.claims.ids
return claim
@property
def claims(self) -> ClaimList:
return ClaimList(self.message)

View file

@ -162,6 +162,13 @@ def guess_media_type(path):
extension = ext.strip().lower()
if extension[1:]:
if extension in types_map:
return types_map[extension][0]
return f'application/x-ext-{extension[1:]}'
return 'application/octet-stream'
return types_map[extension]
return f'application/x-ext-{extension[1:]}', 'binary'
return 'application/octet-stream', 'binary'
def guess_stream_type(media_type):
for media, stream in types_map.values():
if media == media_type:
return stream
return 'binary'

File diff suppressed because one or more lines are too long

View file

@ -125,7 +125,7 @@ class ManagedStream:
def as_dict(self) -> typing.Dict:
full_path = self.full_path if self.output_file_exists else None
mime_type = guess_media_type(os.path.basename(self.descriptor.suggested_file_name))
mime_type = guess_media_type(os.path.basename(self.descriptor.suggested_file_name))[0]
if self.downloader and self.downloader.written_bytes:
written_bytes = self.downloader.written_bytes

View file

@ -122,6 +122,10 @@ class Output(BaseOutput):
self.claim.signature = private_key.sign_digest_deterministic(digest, hashfunc=hashlib.sha256)
self.script.generate()
def clear_signature(self):
self.channel = None
self.claim.clear_signature()
def generate_channel_private_key(self):
private_key = ecdsa.SigningKey.generate(curve=ecdsa.SECP256k1, hashfunc=hashlib.sha256)
self.private_key = private_key.to_pem().decode()
@ -188,15 +192,17 @@ class Transaction(BaseTransaction):
@classmethod
def claim_update(
cls, previous_claim: Output, amount: int, holding_address: str,
cls, previous_claim: Output, claim: Claim, amount: int, holding_address: str,
funding_accounts: List[Account], change_account: Account, signing_channel: Output = None):
ledger = cls.ensure_all_have_same_ledger(funding_accounts, change_account)
updated_claim = Output.pay_update_claim_pubkey_hash(
amount, previous_claim.claim_name, previous_claim.claim_id,
previous_claim.claim, ledger.address_to_hash160(holding_address)
claim, ledger.address_to_hash160(holding_address)
)
if signing_channel is not None:
updated_claim.sign(signing_channel, b'placeholder txid:nout')
else:
updated_claim.clear_signature()
return cls.create(
[Input.spend(previous_claim)], [updated_claim], funding_accounts, change_account, sign=False
)

View file

@ -1,6 +1,9 @@
import os.path
import hashlib
import tempfile
import logging
from binascii import unhexlify
from urllib.request import urlopen
import ecdsa
@ -12,6 +15,9 @@ from lbrynet.testcase import CommandTestCase
from torba.client.hash import sha256, Base58
log = logging.getLogger(__name__)
class ChannelCommands(CommandTestCase):
async def test_create_channel_names(self):
@ -69,74 +75,55 @@ class ChannelCommands(CommandTestCase):
async def test_setting_channel_fields(self):
values = {
'tags': ["cool", "awesome"],
'title': "Cool Channel",
'description': "Best channel on LBRY.",
'thumbnail_url': "https://co.ol/thumbnail.png",
'tags': ["cool", "awesome"],
'languages': ["en-US"],
'locations': ['US::Manchester'],
'email': "human@email.com",
'website_url': "https://co.ol",
'cover_url': "https://co.ol/cover.png",
'featured': ['cafe']
}
fixed_values = values.copy()
fixed_values['languages'] = ['en-US']
fixed_values['locations'] = [{'country': 'US', 'city': 'Manchester'}]
fixed_values['thumbnail'] = {'url': fixed_values.pop('thumbnail_url')}
fixed_values['locations'] = [{'country': 'US', 'city': 'Manchester'}]
fixed_values['cover'] = {'url': fixed_values.pop('cover_url')}
# create new channel with all fields set
tx = await self.out(self.channel_create('@bigchannel', **values))
txo = tx['outputs'][0]
self.assertEqual(
txo['value'],
{'public_key': txo['value']['public_key'], **fixed_values}
)
channel = tx['outputs'][0]['value']
self.assertEqual(channel, {'public_key': channel['public_key'], **fixed_values})
# create channel with nothing set
tx = await self.out(self.channel_create('@lightchannel'))
txo = tx['outputs'][0]
self.assertEqual(
txo['value'],
{'public_key': txo['value']['public_key']}
)
channel = tx['outputs'][0]['value']
self.assertEqual(channel, {'public_key': channel['public_key']})
# create channel with just some tags
tx = await self.out(self.channel_create('@updatedchannel', tags='blah'))
# create channel with just a featured claim
tx = await self.out(self.channel_create('@featurechannel', featured='beef'))
txo = tx['outputs'][0]
claim_id = txo['claim_id']
public_key = txo['value']['public_key']
self.assertEqual(
txo['value'],
{'public_key': public_key, 'tags': ['blah']}
)
claim_id, channel = txo['claim_id'], txo['value']
fixed_values['public_key'] = channel['public_key']
self.assertEqual(channel, {'public_key': fixed_values['public_key'], 'featured': ['beef']})
# update channel setting all fields
tx = await self.out(self.channel_update(claim_id, **values))
txo = tx['outputs'][0]
fixed_values['public_key'] = public_key
fixed_values['tags'].insert(0, 'blah') # existing tag
self.assertEqual(
txo['value'],
fixed_values
)
channel = tx['outputs'][0]['value']
fixed_values['featured'].insert(0, 'beef') # existing featured claim
self.assertEqual(channel, fixed_values)
# clearing and settings tags
tx = await self.out(self.channel_update(claim_id, tags='single', clear_tags=True))
txo = tx['outputs'][0]
fixed_values['tags'] = ['single']
self.assertEqual(
txo['value'],
fixed_values
)
# clearing and settings featured content
tx = await self.out(self.channel_update(claim_id, featured='beefcafe', clear_featured=True))
channel = tx['outputs'][0]['value']
fixed_values['featured'] = ['beefcafe']
self.assertEqual(channel, fixed_values)
# reset signing key
tx = await self.out(self.channel_update(claim_id, new_signing_key=True))
txo = tx['outputs'][0]
self.assertNotEqual(
txo['value']['public_key'],
fixed_values['public_key']
)
channel = tx['outputs'][0]['value']
self.assertNotEqual(channel['public_key'], fixed_values['public_key'])
# send channel to someone else
new_account = await self.out(self.daemon.jsonrpc_account_create('second account'))
@ -168,6 +155,19 @@ class ChannelCommands(CommandTestCase):
class StreamCommands(CommandTestCase):
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')
def setUp(self):
if not os.path.exists(self.video_file_name):
if not os.path.exists(self.files_directory):
os.mkdir(self.files_directory)
log.info(f'downloading test video from {self.video_file_name}')
with urlopen(self.video_file_url) as response,\
open(self.video_file_name, 'wb') as video_file:
video_file.write(response.read())
async def test_create_stream_names(self):
# claim new name
await self.stream_create('foo')
@ -207,17 +207,17 @@ class StreamCommands(CommandTestCase):
tx = await self.stream_update(claim_id, bid='3.0')
self.assertEqual(tx['outputs'][0]['amount'], '3.0')
await self.assertBalance(self.account, '6.993384')
await self.assertBalance(self.account, '6.993337')
# not enough funds
with self.assertRaisesRegex(
InsufficientFundsError, "Not enough funds to cover this transaction."):
await self.stream_create('foo2', '9.0')
self.assertEqual(len(await self.daemon.jsonrpc_claim_list()), 1)
await self.assertBalance(self.account, '6.993384')
await self.assertBalance(self.account, '6.993337')
# spend exactly amount available, no change
tx = await self.stream_create('foo3', '6.98527700')
tx = await self.stream_create('foo3', '6.98523')
await self.assertBalance(self.account, '0.0')
self.assertEqual(len(tx['outputs']), 1) # no change
self.assertEqual(len(await self.daemon.jsonrpc_claim_list()), 2)
@ -264,12 +264,12 @@ class StreamCommands(CommandTestCase):
async def test_setting_stream_fields(self):
values = {
'tags': ["cool", "awesome"],
'title': "Cool Content",
'description': "Best content on LBRY.",
'thumbnail_url': "https://co.ol/thumbnail.png",
'tags': ["cool", "awesome"],
'languages': ["en"],
'locations': ['{"country": "UA"}'],
'locations': ['{"country": "US"}'],
'author': "Jules Verne",
'license': 'Public Domain',
@ -279,16 +279,13 @@ class StreamCommands(CommandTestCase):
'fee_currency': 'usd',
'fee_amount': '2.99',
'fee_address': 'mmCsWAiXMUVecFQ3fVzUwvpT9XFMXno2Ca',
'video_width': 800,
'video_height': 600
}
fixed_values = values.copy()
fixed_values['languages'] = ['en']
fixed_values['locations'] = [{'country': 'UA'}]
fixed_values['locations'] = [{'country': 'US'}]
fixed_values['thumbnail'] = {'url': fixed_values.pop('thumbnail_url')}
fixed_values['release_time'] = str(values['release_time'])
fixed_values['source'] = {
'hash': 'c0ddd62c7717180e7ffb8a15bb9674d3ec92592e0b7ac7d1d5289836b4553be2',
'media_type': 'application/octet-stream',
'size': '3'
}
@ -297,57 +294,68 @@ class StreamCommands(CommandTestCase):
'amount': float(fixed_values.pop('fee_amount')),
'currency': fixed_values.pop('fee_currency').upper()
}
fixed_values['video'] = {
'height': fixed_values.pop('video_height'),
'width': fixed_values.pop('video_width')
}
# create new channel with all fields set
# create new stream with all fields set
tx = await self.out(self.stream_create('big', **values))
txo = tx['outputs'][0]
stream = txo['value']
stream = tx['outputs'][0]['value']
fixed_values['source']['name'] = stream['source']['name']
fixed_values['source']['sd_hash'] = stream['source']['sd_hash']
self.assertEqual(stream, fixed_values)
# create channel with nothing set
# create stream with nothing set
tx = await self.out(self.stream_create('light'))
txo = tx['outputs'][0]
stream = tx['outputs'][0]['value']
self.assertEqual(
txo['value'], {
stream, {
'source': {
'size': '3',
'media_type': 'application/octet-stream',
'sd_hash': txo['value']['source']['sd_hash']
'name': stream['source']['name'],
'hash': 'c0ddd62c7717180e7ffb8a15bb9674d3ec92592e0b7ac7d1d5289836b4553be2',
'sd_hash': stream['source']['sd_hash']
},
}
)
# create channel with just some tags
tx = await self.out(self.stream_create('updated', tags='blah'))
# create stream with just some tags, langs and locations
tx = await self.out(self.stream_create('updated', tags='blah', languages='uk', locations='UA::Kyiv'))
txo = tx['outputs'][0]
claim_id = txo['claim_id']
fixed_values['source']['sd_hash'] = txo['value']['source']['sd_hash']
claim_id, stream = txo['claim_id'], txo['value']
fixed_values['source']['name'] = stream['source']['name']
fixed_values['source']['sd_hash'] = stream['source']['sd_hash']
self.assertEqual(
txo['value'], {
stream, {
'source': {
'size': '3',
'media_type': 'application/octet-stream',
'name': fixed_values['source']['name'],
'hash': 'c0ddd62c7717180e7ffb8a15bb9674d3ec92592e0b7ac7d1d5289836b4553be2',
'sd_hash': fixed_values['source']['sd_hash'],
},
'tags': ['blah']
'tags': ['blah'],
'languages': ['uk'],
'locations': [{'country': 'UA', 'city': 'Kyiv'}]
}
)
# update channel setting all fields
# update stream setting all fields, 'source' doesn't change
tx = await self.out(self.stream_update(claim_id, **values))
txo = tx['outputs'][0]
stream = tx['outputs'][0]['value']
fixed_values['tags'].insert(0, 'blah') # existing tag
self.assertEqual(txo['value'], fixed_values)
fixed_values['languages'].insert(0, 'uk') # existing language
fixed_values['locations'].insert(0, {'country': 'UA', 'city': 'Kyiv'}) # existing location
self.assertEqual(stream, fixed_values)
# clearing and settings tags
tx = await self.out(self.stream_update(claim_id, tags='single', clear_tags=True))
tx = await self.out(self.stream_update(
claim_id, tags='single', clear_tags=True,
languages='pt', clear_languages=True,
locations='BR', clear_locations=True,
))
txo = tx['outputs'][0]
fixed_values['tags'] = ['single']
fixed_values['languages'] = ['pt']
fixed_values['locations'] = [{'country': 'BR'}]
self.assertEqual(txo['value'], fixed_values)
# send claim to someone else
@ -365,7 +373,55 @@ class StreamCommands(CommandTestCase):
self.assertEqual(len(await self.daemon.jsonrpc_claim_list()), 2)
self.assertEqual(len(await self.daemon.jsonrpc_claim_list(account_id=account2_id)), 1)
async def test_create_update_and_abandon_claim(self):
async def test_automatic_type_and_metadata_detection(self):
tx = await self.out(
self.daemon.jsonrpc_stream_create(
'chrome', '1.0', file_path=self.video_file_name
)
)
txo = tx['outputs'][0]
self.assertEqual(
txo['value'], {
'source': {
'size': '2299653',
'name': 'ForBiggerEscapes.mp4',
'media_type': 'video/mp4',
'hash': 'f846d9c7f5ed28f0ed47e9d9b4198a03075e6df967ac54078af85ea1bf0ddd87',
'sd_hash': txo['value']['source']['sd_hash'],
},
'video': {
'width': 1280,
'height': 720,
'duration': 15
}
}
)
async def test_overriding_automatic_metadata_detection(self):
tx = await self.out(
self.daemon.jsonrpc_stream_create(
'chrome', '1.0', file_path=self.video_file_name, width=99, height=88, duration=9
)
)
txo = tx['outputs'][0]
self.assertEqual(
txo['value'], {
'source': {
'size': '2299653',
'name': 'ForBiggerEscapes.mp4',
'media_type': 'video/mp4',
'hash': 'f846d9c7f5ed28f0ed47e9d9b4198a03075e6df967ac54078af85ea1bf0ddd87',
'sd_hash': txo['value']['source']['sd_hash'],
},
'video': {
'width': 99,
'height': 88,
'duration': 9
}
}
)
async def test_create_update_and_abandon_stream(self):
await self.assertBalance(self.account, '10.0')
tx = await self.stream_create(bid='2.5') # creates new claim
@ -385,8 +441,8 @@ class StreamCommands(CommandTestCase):
self.assertEqual(txs[0]['update_info'][0]['balance_delta'], '1.5')
self.assertEqual(txs[0]['update_info'][0]['claim_id'], claim_id)
self.assertEqual(txs[0]['value'], '0.0')
self.assertEqual(txs[0]['fee'], '-0.000184')
await self.assertBalance(self.account, '8.979709')
self.assertEqual(txs[0]['fee'], '-0.0002075')
await self.assertBalance(self.account, '8.9796855')
await self.stream_abandon(claim_id)
txs = await self.out(self.daemon.jsonrpc_transaction_list())
@ -395,9 +451,9 @@ class StreamCommands(CommandTestCase):
self.assertEqual(txs[0]['abandon_info'][0]['claim_id'], claim_id)
self.assertEqual(txs[0]['value'], '0.0')
self.assertEqual(txs[0]['fee'], '-0.000107')
await self.assertBalance(self.account, '9.979602')
await self.assertBalance(self.account, '9.9795785')
async def test_abandoning_claim_at_loss(self):
async def test_abandoning_stream_at_loss(self):
await self.assertBalance(self.account, '10.0')
tx = await self.stream_create(bid='0.0001')
await self.assertBalance(self.account, '9.979793')

View file

@ -4,13 +4,13 @@ from lbrynet.schema import mime_types
class TestMimeTypes(unittest.TestCase):
def test_mp4_video(self):
self.assertEqual("video/mp4", mime_types.guess_media_type("test.mp4"))
self.assertEqual("video/mp4", mime_types.guess_media_type("test.MP4"))
self.assertEqual("video/mp4", mime_types.guess_media_type("test.mp4")[0])
self.assertEqual("video/mp4", mime_types.guess_media_type("test.MP4")[0])
def test_x_ext_(self):
self.assertEqual("application/x-ext-lbry", mime_types.guess_media_type("test.lbry"))
self.assertEqual("application/x-ext-lbry", mime_types.guess_media_type("test.LBRY"))
self.assertEqual("application/x-ext-lbry", mime_types.guess_media_type("test.lbry")[0])
self.assertEqual("application/x-ext-lbry", mime_types.guess_media_type("test.LBRY")[0])
def test_octet_stream(self):
self.assertEqual("application/octet-stream", mime_types.guess_media_type("test."))
self.assertEqual("application/octet-stream", mime_types.guess_media_type("test"))
self.assertEqual("application/octet-stream", mime_types.guess_media_type("test.")[0])
self.assertEqual("application/octet-stream", mime_types.guess_media_type("test")[0])

View file

@ -1,7 +1,7 @@
from unittest import TestCase
from decimal import Decimal
from lbrynet.schema.claim import Claim, Channel, Stream
from lbrynet.schema.claim import Claim, Stream
class TestClaimContainerAwareness(TestCase):
@ -9,27 +9,13 @@ class TestClaimContainerAwareness(TestCase):
def test_stream_claim(self):
stream = Stream()
claim = stream.claim
self.assertTrue(claim.is_stream)
self.assertFalse(claim.is_channel)
self.assertEqual(claim.claim_type, Claim.STREAM)
claim = Claim.from_bytes(claim.to_bytes())
self.assertTrue(claim.is_stream)
self.assertFalse(claim.is_channel)
self.assertEqual(claim.claim_type, Claim.STREAM)
self.assertIsNotNone(claim.stream)
with self.assertRaisesRegex(ValueError, 'Claim is not a channel.'):
print(claim.channel)
def test_channel_claim(self):
channel = Channel()
claim = channel.claim
self.assertFalse(claim.is_stream)
self.assertTrue(claim.is_channel)
claim = Claim.from_bytes(claim.to_bytes())
self.assertFalse(claim.is_stream)
self.assertTrue(claim.is_channel)
self.assertIsNotNone(claim.channel)
with self.assertRaisesRegex(ValueError, 'Claim is not a stream.'):
print(claim.stream)
class TestFee(TestCase):