From 5537dd878f3abc46080e7bb2742699c817c2d897 Mon Sep 17 00:00:00 2001 From: Alex Liebowitz Date: Fri, 26 Aug 2016 06:15:09 -0400 Subject: [PATCH 1/7] Add basic support for streaming partially downloaded files --- lbrynet/lbrynet_daemon/DaemonServer.py | 114 ++++++++++--------------- 1 file changed, 46 insertions(+), 68 deletions(-) diff --git a/lbrynet/lbrynet_daemon/DaemonServer.py b/lbrynet/lbrynet_daemon/DaemonServer.py index 7ddad04ef..7ede73313 100644 --- a/lbrynet/lbrynet_daemon/DaemonServer.py +++ b/lbrynet/lbrynet_daemon/DaemonServer.py @@ -11,9 +11,9 @@ import cgi from appdirs import user_data_dir from twisted.web import server, static, resource -from twisted.internet import defer, interfaces, error, reactor, threads +from twisted.internet import abstract, defer, interfaces, error, reactor, threads -from zope.interface import implements +from zope.interface import implementer from lbrynet.lbrynet_daemon.Daemon import Daemon from lbrynet.conf import API_ADDRESS, UI_ADDRESS, DEFAULT_UI_BRANCH, LOG_FILE_NAME @@ -231,29 +231,31 @@ class LBRYindex(resource.Resource): return static.File(os.path.join(self.ui_dir, "index.html")).render_GET(request) +@implementer(interfaces.IPushProducer) class EncryptedFileStreamer(object): """ - Writes downloaded LBRY file to request as the download comes in, pausing and resuming as requested - used for Chrome + Writes LBRY stream to request; will pause to wait for new data if the file + is downloading. + + No support for range requests (some browser players can't handle it when + the full video data isn't available on request). """ - implements(interfaces.IPushProducer) + bufferSize = abstract.FileDescriptor.bufferSize + delay = 0.25 - def __init__(self, request, path, start, stop, size): + def __init__(self, request, path, total_bytes): self._request = request - self._fileObject = file(path) + self._fileObject = open(path, 'rb') self._content_type = mimetypes.guess_type(path)[0] - self._stop_pos = size - 1 if stop == '' else int(stop) #chrome and firefox send range requests for "0-" - self._cursor = self._start_pos = int(start) - self._file_size = size - self._depth = 0 + self._bytes_written = 0 + self._stopped = False + self._total_bytes = total_bytes - self._paused = self._sent_bytes = self._stopped = False - self._delay = 0.25 self._deferred = defer.succeed(None) - self._request.setResponseCode(206) - self._request.setHeader('accept-ranges', 'bytes') + self._request.setResponseCode(200) + self._request.setHeader('accept-ranges', 'none') self._request.setHeader('content-type', self._content_type) self._request.setHeader("Content-Security-Policy", "sandbox") @@ -266,47 +268,28 @@ class EncryptedFileStreamer(object): def resumeProducing(self): def _check_for_new_data(): - self._depth += 1 - self._fileObject.seek(self._start_pos, os.SEEK_END) - readable_bytes = self._fileObject.tell() - self._fileObject.seek(self._cursor) + data = self._fileObject.read(self.bufferSize) - self._sent_bytes = False + self._request.write(data) + log.info('wrote to request') + self._bytes_written += len(data) - if (readable_bytes > self._cursor) and not (self._stopped or self._paused): - read_length = min(readable_bytes, self._stop_pos) - self._cursor + 1 - self._request.setHeader('content-range', 'bytes %s-%s/%s' % (self._cursor, self._cursor + read_length - 1, self._file_size)) - self._request.setHeader('content-length', str(read_length)) - start_cur = self._cursor - for i in range(read_length): - if self._paused or self._stopped: - break - else: - data = self._fileObject.read(1) - self._request.write(data) - self._cursor += 1 - - log.info("Wrote range %s-%s/%s, length: %s, readable: %s, depth: %s" % - (start_cur, self._cursor, self._file_size, self._cursor - start_cur, readable_bytes, self._depth)) - self._sent_bytes = True - - if self._cursor == self._stop_pos + 1: + if self._bytes_written >= self._total_bytes: self.stopProducing() return defer.succeed(None) - elif self._paused or self._stopped: + elif self._stopped: return defer.succeed(None) else: - self._deferred.addCallback(lambda _: threads.deferToThread(reactor.callLater, self._delay, _check_for_new_data)) + self._deferred.addCallback(lambda _: threads.deferToThread(reactor.callLater, self.delay, _check_for_new_data)) return defer.succeed(None) log.info("Resuming producer") - self._paused = False self._deferred.addCallback(lambda _: _check_for_new_data()) def stopProducing(self): log.info("Stopping producer") self._stopped = True - # self._fileObject.close() + self._fileObject.close() self._deferred.addErrback(lambda err: err.trap(defer.CancelledError)) self._deferred.addErrback(lambda err: err.trap(error.ConnectionDone)) self._deferred.cancel() @@ -321,33 +304,26 @@ class HostedEncryptedFile(resource.Resource): self._producer = None resource.Resource.__init__(self) - # todo: fix EncryptedFileStreamer and use it instead of static.File - # def makeProducer(self, request, stream): - # def _save_producer(producer): - # self._producer = producer - # return defer.succeed(None) - # - # range_header = request.getAllHeaders()['range'].replace('bytes=', '').split('-') - # start, stop = int(range_header[0]), range_header[1] - # log.info("GET range %s-%s" % (start, stop)) - # path = os.path.join(self._api.download_directory, stream.file_name) - # - # d = stream.get_total_bytes() - # d.addCallback(lambda size: _save_producer(EncryptedFileStreamer(request, path, start, stop, size))) - # d.addCallback(lambda _: request.registerProducer(self._producer, streaming=True)) - # # request.notifyFinish().addCallback(lambda _: self._producer.stopProducing()) - # request.notifyFinish().addErrback(self._responseFailed, d) - # return d + def makeProducer(self, request, stream): + def _save_producer(producer): + self._producer = producer + return defer.succeed(None) + + path = os.path.join(self._api.download_directory, stream.file_name) + + d = stream.get_total_bytes() + d.addCallback(lambda total_bytes: _save_producer(EncryptedFileStreamer(request, path, total_bytes))) + d.addCallback(lambda _: request.registerProducer(self._producer, streaming=True)) + ##request.notifyFinish().addCallback(lambda _: self._producer.stopProducing()) + request.notifyFinish().addErrback(self._responseFailed, d) + return d def render_GET(self, request): request.setHeader("Content-Security-Policy", "sandbox") if 'name' in request.args.keys(): if request.args['name'][0] != 'lbry' and request.args['name'][0] not in self._api.waiting_on.keys(): d = self._api._download_name(request.args['name'][0]) - # d.addCallback(lambda stream: self.makeProducer(request, stream)) - d.addCallback(lambda stream: static.File(os.path.join(self._api.download_directory, - stream.file_name)).render_GET(request)) - + d.addCallback(lambda stream: self.makeProducer(request, stream)) elif request.args['name'][0] in self._api.waiting_on.keys(): request.redirect(UI_ADDRESS + "/?watch=" + request.args['name'][0]) request.finish() @@ -356,11 +332,13 @@ class HostedEncryptedFile(resource.Resource): request.finish() return server.NOT_DONE_YET - # def _responseFailed(self, err, call): - # call.addErrback(lambda err: err.trap(error.ConnectionDone)) - # call.addErrback(lambda err: err.trap(defer.CancelledError)) - # call.addErrback(lambda err: log.info("Error: " + str(err))) - # call.cancel() + def _responseFailed(self, err, call): + log.error("Hosted file response failed with error: " + str(err)) + + #call.addErrback(lambda err: err.trap(error.ConnectionDone)) + #call.addErrback(lambda err: err.trap(defer.CancelledError)) + #call.addErrback(lambda err: log.info("Error: " + str(err))) + #call.cancel() class EncryptedFileUpload(resource.Resource): """ From aa3aff91d041be80abff7d66077ba3056bc83488 Mon Sep 17 00:00:00 2001 From: Alex Liebowitz Date: Wed, 31 Aug 2016 03:49:43 -0400 Subject: [PATCH 2/7] Refactor LBRYStreamProducer and add Content-Length header Also fixes producer pause/unpause behavior and adds slight delay between sending chunks --- lbrynet/lbrynet_daemon/DaemonServer.py | 95 +++++++++++++------------- 1 file changed, 49 insertions(+), 46 deletions(-) diff --git a/lbrynet/lbrynet_daemon/DaemonServer.py b/lbrynet/lbrynet_daemon/DaemonServer.py index 7ede73313..230f152ee 100644 --- a/lbrynet/lbrynet_daemon/DaemonServer.py +++ b/lbrynet/lbrynet_daemon/DaemonServer.py @@ -242,58 +242,68 @@ class EncryptedFileStreamer(object): """ bufferSize = abstract.FileDescriptor.bufferSize - delay = 0.25 - def __init__(self, request, path, total_bytes): + + # How long to wait between sending blocks (needed because some + # video players freeze up if you try to send data too fast) + stream_interval = 0.02 + + # How long to wait before checking again + new_data_check_interval = 0.25 + + + def __init__(self, request, path, stream, file_manager): + def _set_content_length_header(length): + self._request.setHeader('content-length', length) + return defer.succeed(None) + self._request = request - self._fileObject = open(path, 'rb') - self._content_type = mimetypes.guess_type(path)[0] - self._bytes_written = 0 - self._stopped = False - self._total_bytes = total_bytes + self._file = open(path, 'rb') + self._stream = stream + self._file_manager = file_manager - self._deferred = defer.succeed(None) + self._running = True self._request.setResponseCode(200) self._request.setHeader('accept-ranges', 'none') - self._request.setHeader('content-type', self._content_type) + self._request.setHeader('content-type', mimetypes.guess_type(path)[0]) self._request.setHeader("Content-Security-Policy", "sandbox") - self.resumeProducing() + self._deferred = stream.get_total_bytes() + self._deferred.addCallback(_set_content_length_header) + self._deferred.addCallback(lambda _: self.resumeProducing()) def pauseProducing(self): - self._paused = True - log.info("Pausing producer") + self._running = False return defer.succeed(None) def resumeProducing(self): def _check_for_new_data(): - data = self._fileObject.read(self.bufferSize) - - self._request.write(data) - log.info('wrote to request') - self._bytes_written += len(data) - - if self._bytes_written >= self._total_bytes: - self.stopProducing() - return defer.succeed(None) - elif self._stopped: + if not self._running: return defer.succeed(None) + + data = self._file.read(self.bufferSize) + if data: + self._request.write(data) + self._deferred.addCallback(lambda _: threads.deferToThread(reactor.callLater, self.stream_interval, _check_for_new_data)) else: - self._deferred.addCallback(lambda _: threads.deferToThread(reactor.callLater, self.delay, _check_for_new_data)) - return defer.succeed(None) + status = self._file_manager.get_lbry_file_status(self._stream) + if status != ManagedLBRYFileDownloader.STATUS_FINISHED: + self._deferred.addCallback(lambda _: threads.deferToThread(reactor.callLater, self.new_data_check_interval, _check_for_new_data)) + else: + self.stopProducing() - log.info("Resuming producer") + self._running = True self._deferred.addCallback(lambda _: _check_for_new_data()) + return defer.succeed(None) def stopProducing(self): - log.info("Stopping producer") - self._stopped = True - self._fileObject.close() + self._running = False + self._file.close() self._deferred.addErrback(lambda err: err.trap(defer.CancelledError)) self._deferred.addErrback(lambda err: err.trap(error.ConnectionDone)) self._deferred.cancel() - # self._request.finish() + self._request.finish() self._request.unregisterProducer() return defer.succeed(None) @@ -301,20 +311,15 @@ class EncryptedFileStreamer(object): class HostedEncryptedFile(resource.Resource): def __init__(self, api): self._api = api - self._producer = None resource.Resource.__init__(self) - def makeProducer(self, request, stream): - def _save_producer(producer): - self._producer = producer - return defer.succeed(None) - + def _make_stream_producer(self, request, stream): path = os.path.join(self._api.download_directory, stream.file_name) - d = stream.get_total_bytes() - d.addCallback(lambda total_bytes: _save_producer(EncryptedFileStreamer(request, path, total_bytes))) - d.addCallback(lambda _: request.registerProducer(self._producer, streaming=True)) - ##request.notifyFinish().addCallback(lambda _: self._producer.stopProducing()) + producer = EncryptedFileStreamer(request, path, stream, self._api.lbry_file_manager) + d = defer.Deferred(None) + d.addCallback(lambda _: request.registerProducer(producer, streaming=True)) + request.notifyFinish().addCallback(lambda _: producer.stopProducing()) request.notifyFinish().addErrback(self._responseFailed, d) return d @@ -323,7 +328,7 @@ class HostedEncryptedFile(resource.Resource): if 'name' in request.args.keys(): if request.args['name'][0] != 'lbry' and request.args['name'][0] not in self._api.waiting_on.keys(): d = self._api._download_name(request.args['name'][0]) - d.addCallback(lambda stream: self.makeProducer(request, stream)) + d.addCallback(lambda stream: self._make_stream_producer(request, stream)) elif request.args['name'][0] in self._api.waiting_on.keys(): request.redirect(UI_ADDRESS + "/?watch=" + request.args['name'][0]) request.finish() @@ -333,12 +338,10 @@ class HostedEncryptedFile(resource.Resource): return server.NOT_DONE_YET def _responseFailed(self, err, call): - log.error("Hosted file response failed with error: " + str(err)) - - #call.addErrback(lambda err: err.trap(error.ConnectionDone)) - #call.addErrback(lambda err: err.trap(defer.CancelledError)) - #call.addErrback(lambda err: log.info("Error: " + str(err))) - #call.cancel() + call.addErrback(lambda err: err.trap(error.ConnectionDone)) + call.addErrback(lambda err: err.trap(defer.CancelledError)) + call.addErrback(lambda err: log.info("Error: " + str(err))) + call.cancel() class EncryptedFileUpload(resource.Resource): """ From f245822814078777b55538efa1f8a69f071d451f Mon Sep 17 00:00:00 2001 From: Alex Liebowitz Date: Fri, 30 Sep 2016 13:46:43 -0400 Subject: [PATCH 3/7] Refactor of LBRYStreamProducer --- lbrynet/lbrynet_daemon/DaemonServer.py | 50 ++++++++++++++------------ 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/lbrynet/lbrynet_daemon/DaemonServer.py b/lbrynet/lbrynet_daemon/DaemonServer.py index 230f152ee..80882acf1 100644 --- a/lbrynet/lbrynet_daemon/DaemonServer.py +++ b/lbrynet/lbrynet_daemon/DaemonServer.py @@ -11,7 +11,7 @@ import cgi from appdirs import user_data_dir from twisted.web import server, static, resource -from twisted.internet import abstract, defer, interfaces, error, reactor, threads +from twisted.internet import abstract, defer, interfaces, error, reactor, task, threads from zope.interface import implementer @@ -261,6 +261,7 @@ class EncryptedFileStreamer(object): self._file = open(path, 'rb') self._stream = stream self._file_manager = file_manager + self._headers_sent = False self._running = True @@ -273,29 +274,35 @@ class EncryptedFileStreamer(object): self._deferred.addCallback(_set_content_length_header) self._deferred.addCallback(lambda _: self.resumeProducing()) + def _check_for_new_data(self): + def _recurse_or_stop(stream_status): + if not self._running: + return + + if stream_status != ManagedLBRYFileDownloader.STATUS_FINISHED: + self._deferred.addCallback(lambda _: task.deferLater(reactor, self.new_data_check_interval, self._check_for_new_data)) + else: + self.stopProducing() + + if not self._running: + return + + data = self._file.read(self.bufferSize) + if data: + self._request.write(data) + self._deferred.addCallback(lambda _: task.deferLater(reactor, self.stream_interval, self._check_for_new_data)) + else: + # We've written all the bytes currently in the file, but we may + # still be downloading, so check file status to see if we're done. + self._deferred.addCallback(lambda _: self._file_manager.get_lbry_file_status(self._stream)) + self._deferred.addCallback(_recurse_or_stop) + def pauseProducing(self): self._running = False - return defer.succeed(None) def resumeProducing(self): - def _check_for_new_data(): - if not self._running: - return defer.succeed(None) - - data = self._file.read(self.bufferSize) - if data: - self._request.write(data) - self._deferred.addCallback(lambda _: threads.deferToThread(reactor.callLater, self.stream_interval, _check_for_new_data)) - else: - status = self._file_manager.get_lbry_file_status(self._stream) - if status != ManagedLBRYFileDownloader.STATUS_FINISHED: - self._deferred.addCallback(lambda _: threads.deferToThread(reactor.callLater, self.new_data_check_interval, _check_for_new_data)) - else: - self.stopProducing() - self._running = True - self._deferred.addCallback(lambda _: _check_for_new_data()) - return defer.succeed(None) + self._check_for_new_data() def stopProducing(self): self._running = False @@ -305,7 +312,6 @@ class EncryptedFileStreamer(object): self._deferred.cancel() self._request.finish() self._request.unregisterProducer() - return defer.succeed(None) class HostedEncryptedFile(resource.Resource): @@ -317,8 +323,8 @@ class HostedEncryptedFile(resource.Resource): path = os.path.join(self._api.download_directory, stream.file_name) producer = EncryptedFileStreamer(request, path, stream, self._api.lbry_file_manager) - d = defer.Deferred(None) - d.addCallback(lambda _: request.registerProducer(producer, streaming=True)) + request.registerProducer(producer, streaming=True) + request.notifyFinish().addCallback(lambda _: producer.stopProducing()) request.notifyFinish().addErrback(self._responseFailed, d) return d From a6fcd5d1f2f4fc6c3cbabceda5388487d6cdaac2 Mon Sep 17 00:00:00 2001 From: Alex Liebowitz Date: Sat, 1 Oct 2016 23:03:04 -0400 Subject: [PATCH 4/7] More refactoring in LBRYStreamProducer --- lbrynet/lbrynet_daemon/DaemonServer.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/lbrynet/lbrynet_daemon/DaemonServer.py b/lbrynet/lbrynet_daemon/DaemonServer.py index 80882acf1..697a9cffb 100644 --- a/lbrynet/lbrynet_daemon/DaemonServer.py +++ b/lbrynet/lbrynet_daemon/DaemonServer.py @@ -246,9 +246,9 @@ class EncryptedFileStreamer(object): # How long to wait between sending blocks (needed because some # video players freeze up if you try to send data too fast) - stream_interval = 0.02 + stream_interval = 0.01 - # How long to wait before checking again + # How long to wait before checking if new data has been appended to the file new_data_check_interval = 0.25 @@ -287,13 +287,15 @@ class EncryptedFileStreamer(object): if not self._running: return + # Clear the file's EOF indicator by seeking to current position + self._file.seek(self._file.tell()) + data = self._file.read(self.bufferSize) if data: self._request.write(data) - self._deferred.addCallback(lambda _: task.deferLater(reactor, self.stream_interval, self._check_for_new_data)) + if self._running: # .write() can trigger a pause + self._deferred.addCallback(lambda _: task.deferLater(reactor, self.stream_interval, self._check_for_new_data)) else: - # We've written all the bytes currently in the file, but we may - # still be downloading, so check file status to see if we're done. self._deferred.addCallback(lambda _: self._file_manager.get_lbry_file_status(self._stream)) self._deferred.addCallback(_recurse_or_stop) @@ -310,8 +312,8 @@ class EncryptedFileStreamer(object): self._deferred.addErrback(lambda err: err.trap(defer.CancelledError)) self._deferred.addErrback(lambda err: err.trap(error.ConnectionDone)) self._deferred.cancel() - self._request.finish() self._request.unregisterProducer() + self._request.finish() class HostedEncryptedFile(resource.Resource): @@ -325,8 +327,8 @@ class HostedEncryptedFile(resource.Resource): producer = EncryptedFileStreamer(request, path, stream, self._api.lbry_file_manager) request.registerProducer(producer, streaming=True) - request.notifyFinish().addCallback(lambda _: producer.stopProducing()) - request.notifyFinish().addErrback(self._responseFailed, d) + d = request.notifyFinish() + d.addErrback(self._responseFailed, d) return d def render_GET(self, request): From 2253943eba5daa5577d47166874c6e8724c54e93 Mon Sep 17 00:00:00 2001 From: Alex Liebowitz Date: Sun, 2 Oct 2016 02:03:37 -0400 Subject: [PATCH 5/7] Shorten time between sending chunks of video stream --- lbrynet/lbrynet_daemon/DaemonServer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lbrynet/lbrynet_daemon/DaemonServer.py b/lbrynet/lbrynet_daemon/DaemonServer.py index 697a9cffb..0d62ba47d 100644 --- a/lbrynet/lbrynet_daemon/DaemonServer.py +++ b/lbrynet/lbrynet_daemon/DaemonServer.py @@ -246,7 +246,7 @@ class EncryptedFileStreamer(object): # How long to wait between sending blocks (needed because some # video players freeze up if you try to send data too fast) - stream_interval = 0.01 + stream_interval = 0.005 # How long to wait before checking if new data has been appended to the file new_data_check_interval = 0.25 From 04ced2e9757e03049302ac6982edc997e514a659 Mon Sep 17 00:00:00 2001 From: Alex Liebowitz Date: Sun, 2 Oct 2016 02:50:17 -0400 Subject: [PATCH 6/7] Fix import issues in DaemonServer.py --- lbrynet/lbrynet_daemon/DaemonServer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lbrynet/lbrynet_daemon/DaemonServer.py b/lbrynet/lbrynet_daemon/DaemonServer.py index 0d62ba47d..c02ff0cc5 100644 --- a/lbrynet/lbrynet_daemon/DaemonServer.py +++ b/lbrynet/lbrynet_daemon/DaemonServer.py @@ -11,11 +11,12 @@ import cgi from appdirs import user_data_dir from twisted.web import server, static, resource -from twisted.internet import abstract, defer, interfaces, error, reactor, task, threads +from twisted.internet import abstract, defer, interfaces, error, reactor, task from zope.interface import implementer from lbrynet.lbrynet_daemon.Daemon import Daemon +from lbrynet.lbryfilemanager.EncryptedFileDownloader import ManagedEncryptedFileDownloader from lbrynet.conf import API_ADDRESS, UI_ADDRESS, DEFAULT_UI_BRANCH, LOG_FILE_NAME @@ -279,7 +280,7 @@ class EncryptedFileStreamer(object): if not self._running: return - if stream_status != ManagedLBRYFileDownloader.STATUS_FINISHED: + if stream_status != ManagedEncryptedFileDownloader.STATUS_FINISHED: self._deferred.addCallback(lambda _: task.deferLater(reactor, self.new_data_check_interval, self._check_for_new_data)) else: self.stopProducing() From 3ad4ad50ec3cabe40e063b947fe8a59cb7820fe1 Mon Sep 17 00:00:00 2001 From: Jack Date: Sun, 2 Oct 2016 03:33:12 -0400 Subject: [PATCH 7/7] =?UTF-8?q?Bump=20version:=200.6.2=20=E2=86=92=200.6.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- lbrynet/__init__.py | 2 +- packaging/ubuntu/lbry.desktop | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 810011d23..8b784ab48 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.6.2 +current_version = 0.6.3 commit = True tag = True diff --git a/lbrynet/__init__.py b/lbrynet/__init__.py index 5089ac8a4..55b0400f2 100644 --- a/lbrynet/__init__.py +++ b/lbrynet/__init__.py @@ -1,6 +1,6 @@ import logging -__version__ = "0.6.2" +__version__ = "0.6.3" version = tuple(__version__.split('.')) logging.getLogger(__name__).addHandler(logging.NullHandler()) \ No newline at end of file diff --git a/packaging/ubuntu/lbry.desktop b/packaging/ubuntu/lbry.desktop index 48182d854..bffd1eb28 100644 --- a/packaging/ubuntu/lbry.desktop +++ b/packaging/ubuntu/lbry.desktop @@ -1,5 +1,5 @@ [Desktop Entry] -Version=0.6.2 +Version=0.6.3 Name=LBRY Comment=The world's first user-owned content marketplace Icon=lbry