574 lines
16 KiB
Python
574 lines
16 KiB
Python
|
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 google.protobuf.json_format import MessageToDict
|
||
|
|
||
|
from scribe.base58 import Base58, b58_encode
|
||
|
from scribe.error import MissingPublishedFileError, EmptyPublishedFileError
|
||
|
|
||
|
from scribe.schema.mime_types import guess_media_type
|
||
|
from scribe.schema.base import Metadata, BaseMessageList
|
||
|
from scribe.schema.tags import clean_tags, normalize_tag
|
||
|
from scribe.schema.types.v2.claim_pb2 import (
|
||
|
Fee as FeeMessage,
|
||
|
Location as LocationMessage,
|
||
|
Language as LanguageMessage
|
||
|
)
|
||
|
|
||
|
|
||
|
log = logging.getLogger(__name__)
|
||
|
|
||
|
|
||
|
CENT = 1000000
|
||
|
COIN = 100*CENT
|
||
|
|
||
|
|
||
|
def calculate_sha384_file_hash(file_path):
|
||
|
sha384 = hashlib.sha384()
|
||
|
with open(file_path, 'rb') as f:
|
||
|
for chunk in iter(lambda: f.read(128 * sha384.block_size), b''):
|
||
|
sha384.update(chunk)
|
||
|
return sha384.digest()
|
||
|
|
||
|
|
||
|
def country_int_to_str(country: int) -> str:
|
||
|
r = LocationMessage.Country.Name(country)
|
||
|
return r[1:] if r.startswith('R') else r
|
||
|
|
||
|
|
||
|
def country_str_to_int(country: str) -> int:
|
||
|
if len(country) == 3:
|
||
|
country = 'R' + country
|
||
|
return LocationMessage.Country.Value(country)
|
||
|
|
||
|
|
||
|
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 MissingPublishedFileError(file_path)
|
||
|
self.size = os.path.getsize(file_path)
|
||
|
if self.size == 0:
|
||
|
raise EmptyPublishedFileError(file_path)
|
||
|
self.file_hash_bytes = calculate_sha384_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 self.message.hash.hex()
|
||
|
|
||
|
@file_hash.setter
|
||
|
def file_hash(self, file_hash: str):
|
||
|
self.message.hash = bytes.fromhex(file_hash)
|
||
|
|
||
|
@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 self.message.sd_hash.hex()
|
||
|
|
||
|
@sd_hash.setter
|
||
|
def sd_hash(self, sd_hash: str):
|
||
|
self.message.sd_hash = bytes.fromhex(sd_hash)
|
||
|
|
||
|
@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 bt_infohash(self) -> str:
|
||
|
return self.message.bt_infohash.hex()
|
||
|
|
||
|
@bt_infohash.setter
|
||
|
def bt_infohash(self, bt_infohash: str):
|
||
|
self.message.bt_infohash = bytes.fromhex(bt_infohash)
|
||
|
|
||
|
@property
|
||
|
def bt_infohash_bytes(self) -> bytes:
|
||
|
return self.message.bt_infohash.decode()
|
||
|
|
||
|
@bt_infohash_bytes.setter
|
||
|
def bt_infohash_bytes(self, bt_infohash: bytes):
|
||
|
self.message.bt_infohash = bt_infohash
|
||
|
|
||
|
@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 amount:
|
||
|
currency = (currency or self.currency or '').lower()
|
||
|
if not currency:
|
||
|
raise Exception('In order to set a fee amount, please specify a fee currency.')
|
||
|
if currency not in ('lbc', 'btc', 'usd'):
|
||
|
raise Exception(f'Missing or unknown currency provided: {currency}')
|
||
|
setattr(self, currency, Decimal(amount))
|
||
|
elif currency:
|
||
|
raise Exception('In order to set a fee currency, please specify a fee amount.')
|
||
|
if address:
|
||
|
if not self.currency:
|
||
|
raise Exception('In order to set a fee address, please specify a fee amount and currency.')
|
||
|
self.address = address
|
||
|
|
||
|
@property
|
||
|
def currency(self) -> str:
|
||
|
if self.message.currency:
|
||
|
return FeeMessage.Currency.Name(self.message.currency)
|
||
|
|
||
|
@property
|
||
|
def address(self) -> str:
|
||
|
if self.address_bytes:
|
||
|
return b58_encode(self.address_bytes)
|
||
|
|
||
|
@address.setter
|
||
|
def address(self, address: str):
|
||
|
self.address_bytes = 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 self.claim_hash[::-1].hex()
|
||
|
|
||
|
@claim_id.setter
|
||
|
def claim_id(self, claim_id: str):
|
||
|
self.claim_hash = bytes.fromhex(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 and parts[0].isalpha():
|
||
|
self.region = parts.pop(0)
|
||
|
if parts and len(parts[0]) == 3 and parts[0].isdigit():
|
||
|
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 country_int_to_str(self.message.region)
|
||
|
|
||
|
@region.setter
|
||
|
def region(self, region: str):
|
||
|
self.message.region = country_str_to_int(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}')
|
||
|
|
||
|
def to_dict(self):
|
||
|
d = MessageToDict(self.message)
|
||
|
if self.message.longitude:
|
||
|
d['longitude'] = self.longitude
|
||
|
if self.message.latitude:
|
||
|
d['latitude'] = self.latitude
|
||
|
return d
|
||
|
|
||
|
@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 TagList(BaseMessageList[str]):
|
||
|
__slots__ = ()
|
||
|
item_class = str
|
||
|
|
||
|
def append(self, tag: str):
|
||
|
tag = normalize_tag(tag)
|
||
|
if tag and tag not in self.message:
|
||
|
self.message.append(tag)
|