Added torrent support in new webplayer

This commit is contained in:
Fabio Manganiello 2019-07-01 19:32:22 +02:00
parent c78789e644
commit 277d6ec271
23 changed files with 695 additions and 210 deletions

View file

@ -46,11 +46,6 @@
@include appearance(none);
}
&::-webkit-progress-value {
background: $slider-progress-bg;
height: 15px;
}
&::-moz-range-progress {
background: $slider-progress-bg;
height: 15px;

View file

@ -10,6 +10,7 @@
@import 'webpanel/plugins/media/controls';
@import 'webpanel/plugins/media/info';
@import 'webpanel/plugins/media/subs';
@import 'webpanel/plugins/media/torrents';
.media-plugin {
display: flex;
@ -50,5 +51,10 @@
color: $result-item-icon;
margin-right: .5em;
}
.top-buttons {
text-align: right;
float: right;
}
}

View file

@ -12,6 +12,9 @@
padding: .75em .5em;
border-bottom: $default-border-2;
&:nth-child(odd) { background: rgba(255,255,255,0.0); }
&:nth-child(even) { background: $default-bg-3; }
&:hover {
background: $hover-bg;
border-radius: 1em;

View file

@ -0,0 +1,40 @@
.media-plugin {
#media-torrents {
.modal {
width: 90%;
.body {
padding: 0;
text-align: center;
.head {
font-weight: bold;
padding: .5rem 0 2.5rem 0;
background: $torrents-head-bg;
border-bottom: $default-border-3;
}
.transfers-container {
margin: 0;
width: 100%;
}
.transfer {
display: flex;
padding: 1.5rem;
cursor: pointer;
&:nth-child(odd) { background: rgba(255,255,255,0.0); }
&:nth-child(even) { background: $default-bg-3; }
&.selected { background: $selected-bg; }
&:hover {
background: $hover-bg;
border-radius: .5rem;
}
}
}
}
}
}

View file

@ -17,3 +17,5 @@ $subs-control-height: 4rem;
$btn-default-shadow: 2px 2px 2px #ddd;
$btn-hover-default-shadow: 3px 3px 3px #ddd;
$torrents-head-bg: #e8e8e8;

View file

@ -1,4 +1,4 @@
function execute(request) {
function execute(request, timeout=30000) {
var additionalPayload = {};
if (!('target' in request) || !request['target']) {
@ -15,6 +15,10 @@ function execute(request) {
};
}
if (timeout) {
additionalPayload.timeout = timeout;
}
return new Promise((resolve, reject) => {
axios.post('/execute', request, additionalPayload)
.then((response) => {
@ -42,7 +46,7 @@ function execute(request) {
});
}
function request(action, args={}) {
function request(action, args={}, timeout=30000) {
return execute({
type: 'request',
action: action,

View file

@ -103,10 +103,10 @@ MediaHandlers.generic = MediaHandlers.file.extend({
},
methods: {
getMetadata: async function(url) {
getMetadata: async function(item) {
return {
url: url,
title: url,
url: item.url,
title: item.url,
};
},
},

View file

@ -1,4 +1,4 @@
MediaHandlers.torrent = Vue.extend({
MediaHandlers.torrent = MediaHandlers.base.extend({
props: {
bus: { type: Object },
iconClass: {
@ -31,6 +31,12 @@ MediaHandlers.torrent = Vue.extend({
},
},
data: function() {
return {
torrentStatus: {},
};
},
methods: {
matchesUrl: function(url) {
return !!(
@ -40,19 +46,207 @@ MediaHandlers.torrent = Vue.extend({
);
},
getMetadata: function(url) {
// TODO
return {};
getMetadata: async function(item, onlyBase=false) {
let status = {};
if (!onlyBase)
status = await this.status({url: item.url});
let transferInfo = {};
if (item.url in this.torrentStatus)
transferInfo = this.torrentStatus[item.url];
return {...status, ...transferInfo};
},
play: function(item) {
play: async function(item) {
let status = await this.download(item);
status.waitingPlay = true;
if (item.url in this.torrentStatus) {
this.firePlay(event);
}
},
download: function(item) {
pause: async function(item) {
let status = await request('torrent.pause', {torrent: item.torrent});
this.mergeStatus(status);
},
info: function(item) {
remove: async function(item) {
let status = await request('torrent.remove', {torrent: item.torrent});
this.mergeStatus(status);
},
status: async function(item) {
if (item) {
return await request('torrent.status', {
torrent: item.url,
});
}
return await request('torrent.status');
},
download: async function(item) {
let status = await this.status(item.url);
if (status && Object.keys(status).length) {
createNotification({
text: 'This torrent is already being downloaded, please play the downloading local media file instead',
image: {
icon: 'download',
},
});
return status;
}
status = await request(
'torrent.download',
{
torrent: item.url,
_async: true,
is_media: true,
},
timeout=120000 // Wait up to two minutes while downloading enough torrent chunks
);
this.torrentStatus[item.url] = {
...item, ...status,
scheduledPlay: false,
torrentState: status.state,
state: 'idle',
};
return this.torrentStatus[item.url];
},
onTorrentEvent: function(event) {
this.mergeStatus(event);
},
onTorrentQueued: function(event) {
if (!this.mergeStatus(event))
this.torrentStatus[event.url] = event;
createNotification({
text: 'Torrent download queued. Will start playing when enough chunks have been downloaded',
image: {
icon: 'clock',
},
});
},
onTorrentStart: function(event) {
if (!this.mergeStatus(event))
return;
createNotification({
text: 'Download of '.concat(event.name, ' started'),
image: {
icon: 'download',
},
});
},
onTorrentStop: function(event) {
if (!this.mergeStatus(event))
return;
delete this.torrentStatus[event.url];
this.bus.$emit('torrent-status-update', this.torrentStatus);
},
onTorrentProgress: function(event) {
if (!this.mergeStatus(event))
return;
if (this.torrentStatus[event.url].waitingPlay)
this.firePlay(event);
},
onTorrentCompleted: function(event) {
if (!this.mergeStatus(event))
return;
delete this.torrentStatus[event.url];
this.bus.$emit('torrent-status-update', this.torrentStatus);
createNotification({
text: 'Download of '.concat(event.name, ' completed'),
image: {
icon: 'check',
},
});
},
firePlay: function(item) {
if (!item.files || !item.files.length) {
console.warn('Torrent ' + item.url + ' has no media files available yet');
return;
}
if (event.progress < 5) {
console.warn('Please wait for enough chunks to be downloaded before playing');
return;
}
const url = 'file://' + item.files[0];
this.bus.$emit('play', {...item, type: 'file', url: url});
if (this.torrentStatus[item.url].waitingPlay)
this.torrentStatus[item.url].waitingPlay = false;
createNotification({
text: 'Playback of '.concat(item.name, ' started'),
image: {
icon: 'play',
},
});
},
mergeStatus: function(event) {
const torrentState = event.state;
delete event.state;
this.torrentStatus[event.url] = {
...this.torrentStatus[event.url],
...event,
torrentState: torrentState,
};
this.bus.$emit('torrent-status-update', this.torrentStatus);
return this.torrentStatus[event.url];
},
},
created: function() {
registerEventHandler(this.onTorrentStart, 'platypush.message.event.torrent.TorrentDownloadStartEvent');
registerEventHandler(this.onTorrentStop, 'platypush.message.event.torrent.TorrentDownloadStopEvent');
registerEventHandler(this.onTorrentProgress, 'platypush.message.event.torrent.TorrentDownloadProgressEvent');
registerEventHandler(this.onTorrentCompleted, 'platypush.message.event.torrent.TorrentDownloadCompletedEvent');
registerEventHandler(this.onTorrentQueued, 'platypush.message.event.torrent.TorrentQueuedEvent');
registerEventHandler(this.onTorrentEvent, 'platypush.message.event.torrent.TorrentPausedEvent',
'platypush.message.event.torrent.TorrentResumedEvent',
'platypush.message.event.torrent.TorrentDownloadStartEvent',
'platypush.message.event.torrent.TorrentStateChangeEvent');
const self = this;
this.status().then((status) => {
if (!status)
return;
for (const [url, torrent] of Object.entries(status)) {
self.torrentStatus[event.url]
self.mergeStatus(torrent);
}
});
setTimeout(() => {
self.bus.$on('torrent-play', self.firePlay);
self.bus.$on('torrent-pause', self.pause);
self.bus.$on('torrent-remove', self.remove);
}, 100);
},
});

View file

@ -41,7 +41,7 @@ MediaHandlers.youtube = MediaHandlers.base.extend({
return !!(url.match('^https?://(www\.)?youtube.com/') || url.match('^https?://youtu.be/') || url.match('^https?://.*googlevideo.com/'));
},
getMetadata: function(url) {
getMetadata: function(item) {
return {};
},

View file

@ -61,6 +61,11 @@ Vue.component('media', {
item: {},
},
torrentModal: {
visible: false,
items: {},
},
subsModal: {
visible: false,
},
@ -71,6 +76,10 @@ Vue.component('media', {
types: function() {
return MediaHandlers;
},
torrentsDownloading: function() {
return Object.entries(this.torrentModal.items).length > 0;
},
},
methods: {
@ -154,10 +163,7 @@ Vue.component('media', {
},
info: function(item) {
for (const [attr, value] of Object.entries(item)) {
Vue.set(this.infoModal.item, attr, value);
}
Vue.set(this.infoModal, 'item', item);
this.infoModal.loading = false;
this.infoModal.visible = true;
},
@ -213,6 +219,14 @@ Vue.component('media', {
};
},
torrentStatusUpdate: function(torrents) {
Vue.set(this.torrentModal, 'items', {});
for (const [url, torrent] of Object.entries(torrents)) {
Vue.set(this.torrentModal.items, url, torrent);
}
},
onStatusUpdate: function(event) {
const dev = event.device;
const status = event.status;
@ -307,6 +321,7 @@ Vue.component('media', {
this.bus.$on('status-update', this.onStatusUpdate);
this.bus.$on('start-streaming', this.startStreaming);
this.bus.$on('search-subs', this.searchSubs);
this.bus.$on('torrent-status-update', this.torrentStatusUpdate);
setInterval(this.timerFunc, 1000);
},

View file

@ -0,0 +1,68 @@
Vue.component('media-torrents', {
template: '#tmpl-media-torrents',
mixins: [mediaUtils],
props: {
bus: { type: Object },
torrents: {
type: Object,
default: () => {},
},
},
data: function() {
return {
selectedItem: undefined,
};
},
computed: {
dropdownItems: function() {
const self = this;
return [
{
name: 'play',
text: 'Play',
iconClass: 'fa fa-play',
click: function() {
self.bus.$emit('torrent-play', self.selectedItem);
},
},
{
name: 'pause',
text: 'Pause/unpause transfer',
iconClass: 'fa fa-pause',
click: function() {
self.bus.$emit('torrent-pause', self.selectedItem);
},
},
{
name: 'cancel',
text: 'Cancel transfer',
iconClass: 'fa fa-trash',
click: function() {
self.bus.$emit('torrent-remove', self.selectedItem);
},
},
{
name: 'info',
text: 'View details',
iconClass: 'fa fa-info',
click: function() {
self.bus.$emit('info', self.selectedItem);
},
},
];
},
},
methods: {
openDropdown: function(item) {
this.selectedItem = item;
openDropdown(this.$refs.menu);
},
},
});

View file

@ -25,6 +25,8 @@
<img :src="image.src" v-if="image && image.src">
<i :class="['fa', 'fa-' + image.icon]" :style="image.color ? '--color: ' + image.color : ''"
v-else-if="image && image.icon"></i>
<i :class="image.iconClass" :style="image.color ? '--color: ' + image.color : ''"
v-else-if="image && image.iconClass"></i>
<i class="fa fa-exclamation" v-else-if="warning"></i>
<i class="fa fa-times" v-else-if="error"></i>
</div>

View file

@ -4,6 +4,7 @@
{% include 'plugins/media/item.html' %}
{% include 'plugins/media/info.html' %}
{% include 'plugins/media/subs.html' %}
{% include 'plugins/media/torrents.html' %}
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/handlers/base.js') }}"></script>
@ -18,13 +19,20 @@
<script type="text/x-template" id="tmpl-media">
<div class="plugin media-plugin">
<div class="search">
<div class="col-11">
<div class="col-10">
<media-search :bus="bus"
:supportedTypes="types">
</media-search>
</div>
<div class="col-1 pull-right">
<div class="col-2 top-buttons">
<button type="button"
v-if="torrentsDownloading"
@click="torrentModal.visible = !torrentModal.visible"
title="Show torrents progress">
<i class="fa fa-download"></i>
</button>
<media-devices :bus="bus"
:localPlayer="player">
</media-devices>
@ -55,6 +63,13 @@
ref="subs">
</media-subs>
</modal>
<modal id="media-torrents" title="Torrents progress" v-model="torrentModal.visible">
<media-torrents :bus="bus"
:torrents="torrentModal.items"
ref="torrents">
</media-torrents>
</modal>
</div>
</script>

View file

@ -11,12 +11,17 @@
<div class="row" v-if="item.title">
<div class="col-3 attr">Title</div>
<div class="col-9 value" v-text="item.title"></div>
<div class="col-9 value">
<a :href="'https://imdb.com/title/' + item.imdb_id" v-if="item.imdb_id" target="_blank">
{% raw %}{{ item.title }}{% endraw %}
</a>
<span v-text="item.title" v-else></span>
</div>
</div>
<div class="row" v-if="item.description">
<div class="row" v-if="item.description || item.synopsis">
<div class="col-3 attr">Description</div>
<div class="col-9 value" v-text="item.description"></div>
<div class="col-9 value" v-text="item.description || item.synopsis"></div>
</div>
<div class="row" v-if="item.channelTitle">
@ -43,6 +48,16 @@
<div class="col-3 attr">Size</div>
<div class="col-9 value" v-text="convertSize(item.size)"></div>
</div>
<div class="row" v-if="item.peers != null || item.num_peers != null">
<div class="col-3 attr">Peers</div>
<div class="col-9 value" v-text="item.peers || item.num_peers"></div>
</div>
<div class="row" v-if="item.seeds != null">
<div class="col-3 attr">Seeds</div>
<div class="col-9 value" v-text="item.seeds"></div>
</div>
</div>
</script>

View file

@ -0,0 +1,31 @@
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/torrents.js') }}"></script>
<script type="text/x-template" id="tmpl-media-torrents">
<div class="transfers-container">
<div class="head">
<div class="col-4">Name</div>
<div class="col-2">State</div>
<div class="col-2">Progress</div>
<div class="col-2">Size</div>
<div class="col-2">DL rate</div>
</div>
<div class="transfers">
<div class="transfer"
:class="{selected: selectedItem && torrent.url === selectedItem.url}"
@click="openDropdown(torrent)"
v-for="torrent in torrents"
:key="torrent.url">
<div class="col-4 name" v-text="torrent.name || torrent.url"></div>
<div class="col-2 state" v-text="torrent.paused ? 'paused' : torrent.torrentState"></div>
<div class="col-2 progress">{% raw %}{{ torrent.progress + '%' }}{% endraw %}</div>
<div class="col-2 size" v-text="convertSize(torrent.size)"></div>
<div class="col-2 rate">{% raw %}{{ convertSize(torrent.download_rate) + '/s' }}{% endraw %}</div>
</div>
</div>
<dropdown ref="menu" :items="dropdownItems">
</dropdown>
</div>
</script>

View file

@ -5,7 +5,7 @@ import time
from queue import Queue
from platypush.config import Config
from platypush.message.event import Event, StopEvent
from platypush.message.event import StopEvent
logger = logging.getLogger(__name__)
@ -13,8 +13,7 @@ logger = logging.getLogger(__name__)
class Bus(object):
""" Main local bus where the daemon will listen for new messages """
_MSG_EXPIRY_TIMEOUT = 60.0 # Consider a message on the bus as expired
# after one minute without being picked up
_MSG_EXPIRY_TIMEOUT = 60.0 # Consider a message on the bus as expired after one minute without being picked up
def __init__(self, on_message=None):
self.bus = Queue()
@ -60,8 +59,8 @@ class Bus(object):
while not stop:
msg = self.get()
if msg.timestamp and time.time() - msg.timestamp > self._MSG_EXPIRY_TIMEOUT:
logger.debug('{} seconds old message on the bus expired, ignoring it: {}'
.format(int(time.time()-msg.timestamp), msg))
logger.debug('{} seconds old message on the bus expired, ignoring it: {}'.
format(int(time.time()-msg.timestamp), msg))
continue
threading.Thread(target=self._msg_executor(msg)).start()

View file

@ -5,72 +5,88 @@ class TorrentEvent(Event):
"""
Base class for torrent events
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class TorrentDownloadingMetadataEvent(TorrentEvent):
class TorrentQueuedEvent(TorrentEvent):
"""
Event triggered upon torrent metadata download start
Event triggered upon when a new torrent transfer is queued
"""
def __init__(self, url, *args, **kwargs):
super().__init__(*args, url=url, **kwargs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class TorrentDownloadedMetadataEvent(TorrentEvent):
"""
Event triggered upon torrent metadata download completed
"""
def __init__(self, url, *args, **kwargs):
super().__init__(*args, url=url, **kwargs)
class TorrentDownloadStartEvent(TorrentEvent):
"""
Event triggered upon torrent download start
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __init__(self, url, *args, **kwargs):
super().__init__(*args, url=url, **kwargs)
class TorrentSeedingStartEvent(TorrentEvent):
"""
Event triggered upon torrent seeding start
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __init__(self, url, *args, **kwargs):
super().__init__(*args, url=url, **kwargs)
class TorrentDownloadProgressEvent(TorrentEvent):
"""
Event triggered upon torrent download progress
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __init__(self, url, *args, **kwargs):
super().__init__(*args, url=url, **kwargs)
class TorrentStateChangeEvent(TorrentEvent):
"""
Event triggered upon torrent state change
"""
def __init__(self, url, *args, **kwargs):
super().__init__(*args, url=url, **kwargs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class TorrentPausedEvent(TorrentEvent):
"""
Event triggered when a torrent transfer is paused
"""
def __init__(self, url, *args, **kwargs):
super().__init__(*args, url=url, **kwargs)
class TorrentResumedEvent(TorrentEvent):
"""
Event triggered when a torrent transfer is resumed
"""
def __init__(self, url, *args, **kwargs):
super().__init__(*args, url=url, **kwargs)
class TorrentDownloadCompletedEvent(TorrentEvent):
"""
Event triggered upon torrent state change
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __init__(self, url, *args, **kwargs):
super().__init__(*args, url=url, **kwargs)
class TorrentDownloadStopEvent(TorrentEvent):
"""
Event triggered when a torrent transfer is stopped
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __init__(self, url, *args, **kwargs):
super().__init__(*args, url=url, **kwargs)
# vim:sw=4:ts=4:et:

View file

@ -30,8 +30,6 @@ class MediaPlugin(Plugin):
Requires:
* A media player installed (supported so far: mplayer, vlc, mpv, omxplayer, chromecast)
* The :class:`platypush.plugins.media.webtorrent` plugin for optional torrent support through webtorrent
(recommended)
* **python-libtorrent** (``pip install python-libtorrent``), optional, for torrent support over native library
* **youtube-dl** installed on your system (see your distro instructions), optional for YouTube support
* **requests** (``pip install requests``), optional, for local files over HTTP streaming supporting
@ -138,9 +136,16 @@ class MediaPlugin(Plugin):
os.makedirs(self.download_dir, exist_ok=True)
self.media_dirs.add(self.download_dir)
self._is_playing_torrent = False
self._videos_queue = []
@staticmethod
def _torrent_event_handler(evt_queue):
def handler(event):
# More than 5% of the torrent has been downloaded
if event.args.get('progress', 0) > 5 and event.args.get('files'):
evt_queue.put(event.args['files'])
return handler
def _get_resource(self, resource):
"""
:param resource: Resource to play/parse. Supported types:
@ -159,33 +164,28 @@ class MediaPlugin(Plugin):
resource = self.get_youtube_url(resource).output
elif resource.startswith('magnet:?'):
try:
get_plugin('media.webtorrent')
return resource # media.webtorrent will handle this
except Exception:
pass
torrents = get_plugin('torrent')
self.logger.info('Downloading torrent {} to {}'.format(
resource, self.download_dir))
torrents = get_plugin('torrent')
evt_queue = queue.Queue()
torrents.download(resource, download_dir=self.download_dir, _async=True, is_media=True,
event_hndl=self._torrent_event_handler(evt_queue))
resources = [f for f in evt_queue.get()]
response = torrents.download(resource, download_dir=self.download_dir)
resources = [f for f in response.output if self._is_video_file(f)]
if resources:
self._videos_queue = sorted(resources)
resource = self._videos_queue.pop(0)
else:
raise RuntimeError('Unable to download torrent {}'.format(resource))
raise RuntimeError('No media file found in torrent {}'.format(resource))
return resource
def _stop_torrent(self):
if self._is_playing_torrent:
try:
get_plugin('media.webtorrent').quit()
except Exception as e:
self.logger.warning('Cannot quit the webtorrent instance: {}'.
format(str(e)))
@staticmethod
def _stop_torrent():
torrents = get_plugin('torrent')
torrents.quit()
@action
def play(self, resource, *args, **kwargs):
@ -492,8 +492,7 @@ class MediaPlugin(Plugin):
[float(t) * pow(60, i) for (i, t) in enumerate(re.search(
'^Duration:\s*([^,]+)', [x.decode()
for x in result.stdout.readlines()
if "Duration" in x.decode()]
.pop().strip()
if "Duration" in x.decode()].pop().strip()
).group(1).split(':')[::-1])]
)

View file

@ -61,7 +61,6 @@ class MediaMplayerPlugin(MediaPlugin):
self._mplayer_timeout = mplayer_timeout
self._mplayer_stopped_event = threading.Event()
self._status_lock = threading.Lock()
self._is_playing_torrent = False
def _init_mplayer_bin(self, mplayer_bin=None):
if not mplayer_bin:
@ -259,11 +258,7 @@ class MediaMplayerPlugin(MediaPlugin):
resource = self._get_resource(resource)
if resource.startswith('file://'):
resource = resource[7:]
elif resource.startswith('magnet:?'):
self._is_playing_torrent = True
return get_plugin('media.webtorrent').play(resource)
self._is_playing_torrent = False
self._exec('loadfile', resource, mplayer_args=mplayer_args)
self._post_event(MediaPlayEvent, resource=resource)
return self.status()

View file

@ -2,7 +2,7 @@ import os
import re
import threading
from platypush.context import get_bus, get_plugin
from platypush.context import get_bus
from platypush.plugins.media import PlayerState, MediaPlugin
from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \
MediaPauseEvent, MediaStopEvent, NewPlayingMediaEvent, MediaSeekEvent
@ -42,7 +42,6 @@ class MediaMpvPlugin(MediaPlugin):
self.args.update(args)
self._player = None
self._is_playing_torrent = False
self._playback_rebounce_event = threading.Event()
self._on_stop_callbacks = []
@ -77,7 +76,8 @@ class MediaMpvPlugin(MediaPlugin):
if (evt == Event.FILE_LOADED or evt == Event.START_FILE) and self._get_current_resource():
self._playback_rebounce_event.set()
self._post_event(NewPlayingMediaEvent, resource=self._get_current_resource(), title=self._player.filename)
self._post_event(NewPlayingMediaEvent, resource=self._get_current_resource(),
title=self._player.filename)
elif evt == Event.PLAYBACK_RESTART:
self._playback_rebounce_event.set()
elif evt == Event.PAUSE:
@ -85,8 +85,8 @@ class MediaMpvPlugin(MediaPlugin):
elif evt == Event.UNPAUSE:
self._post_event(MediaPlayEvent, resource=self._get_current_resource(), title=self._player.filename)
elif evt == Event.SHUTDOWN or (
evt == Event.END_FILE and event.get('event', {}).get('reason')
in [EndFile.EOF_OR_INIT_FAILURE, EndFile.ABORTED, EndFile.QUIT]):
evt == Event.END_FILE and event.get('event', {}).get('reason') in
[EndFile.EOF_OR_INIT_FAILURE, EndFile.ABORTED, EndFile.QUIT]):
playback_rebounced = self._playback_rebounce_event.wait(timeout=0.5)
if playback_rebounced:
self._playback_rebounce_event.clear()
@ -95,8 +95,8 @@ class MediaMpvPlugin(MediaPlugin):
self._player = None
self._post_event(MediaStopEvent)
for callback in self._on_stop_callbacks:
callback()
for cbk in self._on_stop_callbacks:
cbk()
elif evt == Event.SEEK:
self._post_event(MediaSeekEvent, position=self._player.playback_time)
@ -111,7 +111,8 @@ class MediaMpvPlugin(MediaPlugin):
for regex in regexes:
m = re.search(regex, resource)
if m: return base_url + m.group(2)
if m:
return base_url + m.group(2)
return None
@action
@ -149,14 +150,11 @@ class MediaMpvPlugin(MediaPlugin):
if resource.startswith('file://'):
resource = resource[7:]
elif resource.startswith('magnet:?'):
self._is_playing_torrent = True
return get_plugin('media.webtorrent').play(resource)
else:
yt_resource = self._get_youtube_link(resource)
if yt_resource: resource = yt_resource
if yt_resource:
resource = yt_resource
self._is_playing_torrent = False
self._player.play(resource)
return self.status()
@ -283,7 +281,7 @@ class MediaMpvPlugin(MediaPlugin):
return self._player.sub_remove(sub_id)
@action
def toggle_fullscreen(self, fullscreen=None):
def toggle_fullscreen(self):
""" Toggle the fullscreen mode """
return self.toggle_property('fullscreen')
@ -296,7 +294,7 @@ class MediaMpvPlugin(MediaPlugin):
:param property: Property to toggle
"""
if not self._player:
return (None, 'No mpv instance is running')
return None, 'No mpv instance is running'
if not hasattr(self._player, property):
self.logger.warning('No such mpv property: {}'.format(property))
@ -312,7 +310,7 @@ class MediaMpvPlugin(MediaPlugin):
``man mpv`` for a full list of the available properties
"""
if not self._player:
return (None, 'No mpv instance is running')
return None, 'No mpv instance is running'
return getattr(self._player, property)
@action
@ -325,7 +323,7 @@ class MediaMpvPlugin(MediaPlugin):
:type props: dict
"""
if not self._player:
return (None, 'No mpv instance is running')
return None, 'No mpv instance is running'
for k,v in props.items():
setattr(self._player, k, v)
@ -340,7 +338,7 @@ class MediaMpvPlugin(MediaPlugin):
def remove_subtitles(self):
""" Removes (hides) the subtitles """
if not self._player:
return (None, 'No mpv instance is running')
return None, 'No mpv instance is running'
self._player.sub_visibility = False
@action

View file

@ -159,9 +159,6 @@ class MediaVlcPlugin(MediaPlugin):
if resource.startswith('file://'):
resource = resource[len('file://'):]
elif resource.startswith('magnet:?'):
self._is_playing_torrent = True
return get_plugin('media.webtorrent').play(resource)
self._init_vlc(resource)
if subtitles:
@ -169,7 +166,6 @@ class MediaVlcPlugin(MediaPlugin):
subtitles = subtitles[len('file://'):]
self._player.video_set_subtitle_file(subtitles)
self._is_playing_torrent = False
self._player.play()
if fullscreen or self._default_fullscreen:

View file

@ -10,7 +10,7 @@ from platypush.config import Config
from platypush.context import get_bus, get_plugin
from platypush.plugins.media import PlayerState, MediaPlugin
from platypush.message.event.torrent import TorrentDownloadStartEvent, \
TorrentDownloadCompletedEvent, TorrentDownloadingMetadataEvent
TorrentDownloadCompletedEvent, TorrentDownloadedMetadataEvent
from platypush.plugins import action
from platypush.utils import find_bins_in_path, find_files_by_ext, \
@ -142,7 +142,7 @@ class MediaWebtorrentPlugin(MediaPlugin):
and state == TorrentState.IDLE:
# IDLE -> DOWNLOADING_METADATA
state = TorrentState.DOWNLOADING_METADATA
bus.post(TorrentDownloadingMetadataEvent(resource=resource))
bus.post(TorrentDownloadedMetadataEvent(resource=resource))
elif 'downloading: ' in line.lower() \
and media_file is None:
# Find video files in torrent directory

View file

@ -10,9 +10,9 @@ import urllib.parse
from platypush.context import get_bus
from platypush.plugins import Plugin, action
from platypush.message.event.torrent import \
TorrentDownloadStartEvent, TorrentSeedingStartEvent, \
TorrentStateChangeEvent, TorrentDownloadProgressEvent, \
TorrentDownloadCompletedEvent, TorrentDownloadStopEvent
TorrentDownloadStartEvent, TorrentDownloadedMetadataEvent, TorrentStateChangeEvent, \
TorrentDownloadProgressEvent, TorrentDownloadCompletedEvent, TorrentDownloadStopEvent, \
TorrentPausedEvent, TorrentResumedEvent, TorrentQueuedEvent
class TorrentPlugin(Plugin):
@ -25,6 +25,9 @@ class TorrentPlugin(Plugin):
* **requests** (``pip install requests``) [optional] for torrent info URL download
"""
# Wait time in seconds between two torrent transfer checks
_MONITOR_CHECK_INTERVAL = 3
default_torrent_ports = [6881, 6891]
supported_categories = {
'movies': 'https://movies-v2.api-fetch.website/movies/1',
@ -48,6 +51,7 @@ class TorrentPlugin(Plugin):
self.torrent_ports = torrent_ports if torrent_ports else self.default_torrent_ports
self.download_dir = None
self._session = None
if download_dir:
self.download_dir = os.path.abspath(os.path.expanduser(download_dir))
@ -101,7 +105,6 @@ class TorrentPlugin(Plugin):
format(category, self.supported_categories.keys()))
self.logger.info('Searching {} torrents for "{}"'.format(category, query))
url = 'https://{category}-v2.api-fetch.website/{category}/1'.format(category=category)
request = urllib.request.urlopen(urllib.request.Request(
self.supported_categories[category] + '?' + urllib.parse.urlencode({
'sort': 'relevance',
@ -139,41 +142,18 @@ class TorrentPlugin(Plugin):
for (quality, item) in items.items()
], key=lambda _: _.get('seeds'), reverse=True)
@action
def download(self, torrent, download_dir=None):
"""
Download a torrent.
:param torrent: Torrent to download. Supported formats:
* Magnet URLs
* Torrent URLs
* Local torrent file
:type resource: str
:param download_dir: Directory to download, overrides the default download_dir attribute (default: None)
:type download_dir: str
"""
def _get_torrent_info(self, torrent, download_dir):
import libtorrent as lt
if not download_dir:
if self.download_dir:
download_dir = self.download_dir
else:
raise RuntimeError('download_dir not specified')
download_dir = os.path.abspath(os.path.expanduser(download_dir))
os.makedirs(download_dir, exist_ok=True)
info = {}
torrent_file = None
magnet = None
bus = get_bus()
info = {}
file_info = {}
if torrent.startswith('magnet:?'):
magnet = torrent
info = lt.parse_magnet_uri(magnet)
info['magnet'] = magnet
elif torrent.startswith('http://') or torrent.startswith('https://'):
import requests
response = requests.get(torrent, allow_redirects=True)
@ -196,11 +176,139 @@ class TorrentPlugin(Plugin):
'save_path': download_dir,
}
ses = lt.session()
ses.listen_on(*self.torrent_ports)
return info, file_info, torrent_file, magnet
self.logger.info('Downloading "{}" to "{}" from [{}]'
.format(info['name'], self.download_dir, torrent))
def _fire_event(self, event, event_hndl):
bus = get_bus()
bus.post(event)
try:
if event_hndl:
event_hndl(event)
except Exception as e:
self.logger.warning('Exception in torrent event handler: {}'.format(str(e)))
self.logger.exception(e)
def _torrent_monitor(self, torrent, transfer, download_dir, event_hndl, is_media):
def thread():
files = []
last_status = None
download_started = False
metadata_downloaded = False
while not transfer.is_finished():
if torrent not in self.transfers:
self.logger.info('Torrent {} has been stopped and removed'.format(torrent))
self._fire_event(TorrentDownloadStopEvent(url=torrent), event_hndl)
break
status = transfer.status()
torrent_file = transfer.torrent_file()
if torrent_file:
self.torrent_state[torrent]['size'] = torrent_file.total_size()
files = [os.path.join(
download_dir,
torrent_file.files().file_path(i))
for i in range(0, torrent_file.files().num_files())
]
if is_media:
from platypush.plugins.media import MediaPlugin
files = [f for f in files if MediaPlugin._is_video_file(f)]
self.torrent_state[torrent]['download_rate'] = status.download_rate
self.torrent_state[torrent]['name'] = status.name
self.torrent_state[torrent]['num_peers'] = status.num_peers
self.torrent_state[torrent]['paused'] = status.paused
self.torrent_state[torrent]['progress'] = round(100 * status.progress, 2)
self.torrent_state[torrent]['state'] = status.state.name
self.torrent_state[torrent]['title'] = status.name
self.torrent_state[torrent]['torrent'] = torrent
self.torrent_state[torrent]['upload_rate'] = status.upload_rate
self.torrent_state[torrent]['url'] = torrent
self.torrent_state[torrent]['files'] = files
if transfer.has_metadata() and not metadata_downloaded:
self._fire_event(TorrentDownloadedMetadataEvent(**self.torrent_state[torrent]), event_hndl)
metadata_downloaded = True
if status.state == status.downloading and not download_started:
self._fire_event(TorrentDownloadStartEvent(**self.torrent_state[torrent]), event_hndl)
download_started = True
if last_status and status.progress != last_status.progress:
self._fire_event(TorrentDownloadProgressEvent(**self.torrent_state[torrent]), event_hndl)
if not last_status or status.state != last_status.state:
self._fire_event(TorrentStateChangeEvent(**self.torrent_state[torrent]), event_hndl)
if last_status and status.paused != last_status.paused:
if status.paused:
self._fire_event(TorrentPausedEvent(**self.torrent_state[torrent]), event_hndl)
else:
self._fire_event(TorrentResumedEvent(**self.torrent_state[torrent]), event_hndl)
last_status = status
time.sleep(self._MONITOR_CHECK_INTERVAL)
if transfer and transfer.is_finished():
self._fire_event(TorrentDownloadCompletedEvent(**self.torrent_state[torrent]), event_hndl)
self.remove(torrent)
return files
return thread
@action
def download(self, torrent, download_dir=None, _async=False, event_hndl=None, is_media=False):
"""
Download a torrent.
:param torrent: Torrent to download. Supported formats:
* Magnet URLs
* Torrent URLs
* Local torrent file
:type torrent: str
:param download_dir: Directory to download, overrides the default download_dir attribute (default: None)
:type download_dir: str
:param _async: If true then the method will add the torrent to the transfer and then return. Updates on
the download status should be retrieved either by listening to torrent events or registering the
event handler. If false (default) then the method will exit only when the torrent download is complete.
:type _async: bool
:param event_hndl: A function that takes an event object as argument and is invoked upon a new torrent event
(download started, progressing, completed etc.)
:type event_hndl: function
:param is_media: Set it to true if you're downloading a media file that you'd like to stream as soon as the
first chunks are available. If so, then the events and the status method will only include media files
:type is_media: bool
"""
if torrent in self.torrent_state and torrent in self.transfers:
return self.torrent_state[torrent]
import libtorrent as lt
if not download_dir:
if self.download_dir:
download_dir = self.download_dir
else:
raise RuntimeError('download_dir not specified')
download_dir = os.path.abspath(os.path.expanduser(download_dir))
os.makedirs(download_dir, exist_ok=True)
info, file_info, torrent_file, magnet = self._get_torrent_info(torrent, download_dir)
if not self._session:
self._session = lt.session()
self._session.listen_on(*self.torrent_ports)
params = {
'save_path': download_dir,
@ -208,99 +316,65 @@ class TorrentPlugin(Plugin):
}
if magnet:
transfer = lt.add_magnet_uri(ses, magnet, params)
elif torrent_file:
transfer = lt.add_magnet_uri(self._session, magnet, params)
else:
params['ti'] = file_info
transfer = ses.add_torrent(params)
status = transfer.status()
files = []
transfer = self._session.add_torrent(params)
self.transfers[torrent] = transfer
self.torrent_state[torrent] = {
'url': torrent,
'title': info['name'],
'title': transfer.status().name,
'trackers': info['trackers'],
'save_path': download_dir,
}
bus.post(TorrentDownloadStartEvent(**self.torrent_state[torrent]))
last_status = None
self._fire_event(TorrentQueuedEvent(url=torrent), event_hndl)
self.logger.info('Downloading "{}" to "{}" from [{}]'.format(info['name'], download_dir, torrent))
monitor_thread = self._torrent_monitor(torrent=torrent, transfer=transfer, download_dir=download_dir,
event_hndl=event_hndl, is_media=is_media)
while not status.is_seeding:
if not torrent in self.transfers:
self.logger.info('Torrent {} has been stopped and removed')
bus.post(TorrentDownloadStopEvent(url=torrent))
return
if not last_status:
bus.post(TorrentSeedingStartEvent(**self.torrent_state[torrent]))
status = transfer.status()
torrent_file = transfer.torrent_file()
if torrent_file:
files = [os.path.join(
self.download_dir,
torrent_file.files().file_path(i))
for i in range(0, torrent_file.files().num_files())
]
self.torrent_state[torrent]['progress'] = round(100 * status.progress, 2)
self.torrent_state[torrent]['download_rate'] = status.download_rate
self.torrent_state[torrent]['upload_rate'] = status.upload_rate
self.torrent_state[torrent]['num_peers'] = status.num_peers
self.torrent_state[torrent]['state'] = status.state
if last_status and status.progress != last_status.progress:
bus.post(TorrentDownloadProgressEvent(**self.torrent_state[torrent]))
if not last_status or status.state != last_status.state:
bus.post(TorrentStateChangeEvent(**self.torrent_state[torrent]))
self.logger.info(('Torrent download: {:.2f}% complete (down: {:.1f} kb/s ' +
'up: {:.1f} kB/s peers: {} state: {})')
.format(status.progress * 100,
status.download_rate / 1000,
status.upload_rate / 1000,
status.num_peers, status.state))
last_status = status
time.sleep(5)
if torrent_file:
try: os.unlink(torrent_file)
except: pass
bus.post(TorrentDownloadCompletedEvent(**self.torrent_state[torrent], files=files))
del self.torrent_state[torrent]
del self.transfers[torrent]
return files
if not _async:
return monitor_thread()
threading.Thread(target=monitor_thread).start()
return self.torrent_state[torrent]
@action
def get_status(self):
def status(self, torrent=None):
"""
Get the status of the current transfers.
:param torrent: Torrent path, URL or magnet URI whose status will be retrieve (default: None, retrieve all
current transfers)
:type torrent: str
:returns: A dictionary in the format torrent_url -> status
"""
if torrent:
return self.torrent_state.get(torrent)
return self.torrent_state
@action
def pause(self, torrent):
"""
Pause a torrent transfer.
Pause/resume a torrent transfer.
:param torrent: Torrent URL as returned from `get_status()`
:type torrent: str
"""
if torrent not in self.transfers:
return (None, "No transfer in progress for {}".format(torrent))
return None, "No transfer in progress for {}".format(torrent)
if self.torrent_state[torrent].get('paused', False):
self.transfers[torrent].resume()
else:
self.transfers[torrent].pause()
return self.torrent_state[torrent]
@action
def resume(self, torrent):
"""
@ -311,7 +385,7 @@ class TorrentPlugin(Plugin):
"""
if torrent not in self.transfers:
return (None, "No transfer in progress for {}".format(torrent))
return None, "No transfer in progress for {}".format(torrent)
self.transfers[torrent].resume()
@ -325,17 +399,35 @@ class TorrentPlugin(Plugin):
"""
if torrent not in self.transfers:
return (None, "No transfer in progress for {}".format(torrent))
return None, "No transfer in progress for {}".format(torrent)
self.transfers[torrent].pause()
del self.torrent_state[torrent]
del self.transfers[torrent]
def _generate_rand_filename(self, length=16):
if not len(self.transfers):
self.logger.info('No remaining active torrent transfers found, exiting session')
self._session = None
@action
def quit(self):
"""
Quits all the transfers and the active session
"""
if not self._session:
self.logger.info('No active sessions found')
return
transfers = self.transfers.copy()
for torrent in transfers:
self.remove(torrent)
@staticmethod
def _generate_rand_filename(length=16):
name = ''
for i in range(0, length):
name += hex(random.randint(0, 15))[2:].upper()
return name + '.torrent'
# vim:sw=4:ts=4:et:
# vim:sw=4:ts=4:et: