Added full browsing, playlist and search support.
This commit is contained in:
parent
a73edeae8e
commit
d8cbb503a5
6 changed files with 137 additions and 70 deletions
|
@ -12,5 +12,5 @@ class SubidyBackend(pykka.ThreadingActor, backend.Backend):
|
||||||
password=subidy_config['password'])
|
password=subidy_config['password'])
|
||||||
self.library = library.SubidyLibraryProvider(backend=self)
|
self.library = library.SubidyLibraryProvider(backend=self)
|
||||||
self.playback = playback.SubidyPlaybackProvider(audio=audio, backend=self)
|
self.playback = playback.SubidyPlaybackProvider(audio=audio, backend=self)
|
||||||
#self.playlists = playlists.SubidyPlaylistsProvider(backend=self)
|
self.playlists = playlists.SubidyPlaylistsProvider(backend=self)
|
||||||
self.uri_schemes = ['subidy']
|
self.uri_schemes = ['subidy']
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
from mopidy import backend, models
|
from mopidy import backend, models
|
||||||
|
from mopidy.models import Ref, SearchResult
|
||||||
from mopidy_subidy import uri
|
from mopidy_subidy import uri
|
||||||
|
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class SubidyLibraryProvider(backend.LibraryProvider):
|
class SubidyLibraryProvider(backend.LibraryProvider):
|
||||||
root_directory = models.Ref(uri=uri.ROOT_URI, type=models.Ref.DIRECTORY, name='Subsonic')
|
root_directory = Ref.directory(uri=uri.ROOT_URI, name='Subsonic')
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(SubidyLibraryProvider, self).__init__(*args, **kwargs)
|
super(SubidyLibraryProvider, self).__init__(*args, **kwargs)
|
||||||
|
@ -18,13 +22,13 @@ class SubidyLibraryProvider(backend.LibraryProvider):
|
||||||
return self.subsonic_api.get_artists_as_refs()
|
return self.subsonic_api.get_artists_as_refs()
|
||||||
|
|
||||||
def lookup_song(self, song_id):
|
def lookup_song(self, song_id):
|
||||||
return self.subsonic_api.find_song_by_id(song_id)
|
return self.subsonic_api.get_song_by_id(song_id)
|
||||||
|
|
||||||
def lookup_album(self, album_id):
|
def lookup_album(self, album_id):
|
||||||
return self.subsonic_api.find_album_by_id(album_id)
|
return self.subsonic_api.get_album_by_id(album_id)
|
||||||
|
|
||||||
def lookup_artist(self, artist_id):
|
def lookup_artist(self, artist_id):
|
||||||
return self.subsonic_api.find_artist_by_id(artist_id)
|
return self.subsonic_api.get_artist_by_id(artist_id)
|
||||||
|
|
||||||
def browse(self, browse_uri):
|
def browse(self, browse_uri):
|
||||||
type = uri.get_type(browse_uri)
|
type = uri.get_type(browse_uri)
|
||||||
|
@ -53,5 +57,25 @@ class SubidyLibraryProvider(backend.LibraryProvider):
|
||||||
|
|
||||||
def refresh(self, uri):
|
def refresh(self, uri):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def search_uri(self, query):
|
||||||
|
type = uri.get_type(lookup_uri)
|
||||||
|
if type == uri.ARTIST:
|
||||||
|
artist = self.lookup_artist(uri.get_artist_id(lookup_uri))
|
||||||
|
if artist is not None:
|
||||||
|
return SearchResult(artists=[artist])
|
||||||
|
elif type == uri.ALBUM:
|
||||||
|
album = self.lookup_album(uri.get_album_id(lookup_uri))
|
||||||
|
if album is not None:
|
||||||
|
return SearchResult(albums=[album])
|
||||||
|
elif type == uri.SONG:
|
||||||
|
song = self.lookup_song(uri.get_song_id(lookup_uri))
|
||||||
|
if song is not None:
|
||||||
|
return SearchResult(tracks=[song])
|
||||||
|
return None
|
||||||
|
|
||||||
def search(self, query=None, uris=None, exact=False):
|
def search(self, query=None, uris=None, exact=False):
|
||||||
pass
|
if 'uri' in query:
|
||||||
|
return self.search_uri(query.get('uri')[0])
|
||||||
|
if 'any' in query:
|
||||||
|
return self.subsonic_api.find_as_search_result(query.get('any')[0])
|
||||||
|
|
|
@ -1,12 +1,18 @@
|
||||||
from mopidy import backend
|
from mopidy import backend
|
||||||
|
from mopidy_subidy import uri
|
||||||
|
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class SubidyPlaylistsProvider(backend.PlaylistsProvider):
|
class SubidyPlaylistsProvider(backend.PlaylistsProvider):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(SubidyPlaylistsProvider, self).__init__(*args, **kwargs)
|
super(SubidyPlaylistsProvider, self).__init__(*args, **kwargs)
|
||||||
|
self.subsonic_api = self.backend.subsonic_api
|
||||||
self.playlists = []
|
self.playlists = []
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
def as_list(self):
|
def as_list(self):
|
||||||
pass
|
return self.playlists
|
||||||
|
|
||||||
def create(self, name):
|
def create(self, name):
|
||||||
pass
|
pass
|
||||||
|
@ -14,14 +20,16 @@ class SubidyPlaylistsProvider(backend.PlaylistsProvider):
|
||||||
def delete(self, uri):
|
def delete(self, uri):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_items(self, uri):
|
def get_items(self, items_uri):
|
||||||
pass
|
#logger.info('ITEMS %s: %s' % (lookup_uri, self.subsonic_api.get_playlist_songs_as_refs(uri.get_playlist_id(items_uri))))
|
||||||
|
return self.subsonic_api.get_playlist_songs_as_refs(uri.get_playlist_id(items_uri))
|
||||||
|
|
||||||
def lookup(self, uri):
|
def lookup(self, lookup_uri):
|
||||||
pass
|
#logger.info('LOOKUP PLAYLIST %s: %s' % (lookup_uri, self.subsonic_api.get_playlist_as_playlist(uri.get_playlist_id(lookup_uri))))
|
||||||
|
return self.subsonic_api.get_playlist_as_playlist(uri.get_playlist_id(lookup_uri))
|
||||||
|
|
||||||
def refresh(self):
|
def refresh(self):
|
||||||
pass
|
self.playlists = self.subsonic_api.get_playlists_as_refs()
|
||||||
|
|
||||||
def save(self, playlist):
|
def save(self, playlist):
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -2,7 +2,7 @@ from urlparse import urlparse
|
||||||
import libsonic
|
import libsonic
|
||||||
import logging
|
import logging
|
||||||
import itertools
|
import itertools
|
||||||
from mopidy.models import Track, Album, Artist, Playlist, Ref
|
from mopidy.models import Track, Album, Artist, Playlist, Ref, SearchResult
|
||||||
from mopidy_subidy import uri
|
from mopidy_subidy import uri
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -41,32 +41,24 @@ class SubsonicApi():
|
||||||
template = '%s/stream.view?id=%s&u=%s&p=%s&c=mopidy&v=1.14'
|
template = '%s/stream.view?id=%s&u=%s&p=%s&c=mopidy&v=1.14'
|
||||||
return template % (self.url, song_id, self.username, self.password)
|
return template % (self.url, song_id, self.username, self.password)
|
||||||
|
|
||||||
def find_artists_by_name(self, artist_name):
|
def find_raw(self, query, exclude_artists=False, exclude_albums=False, exclude_songs=False):
|
||||||
response = self.connection.search3(artist_name, MAX_SEARCH_RESULTS, 0, 0, 0, 0, 0)
|
response = self.connection.search2(
|
||||||
|
query.encode('utf-8'),
|
||||||
|
MAX_SEARCH_RESULTS if not exclude_artists else 0, 0,
|
||||||
|
MAX_SEARCH_RESULTS if not exclude_albums else 0, 0,
|
||||||
|
MAX_SEARCH_RESULTS if not exclude_songs else 0, 0)
|
||||||
if response.get('status') != RESPONSE_OK:
|
if response.get('status') != RESPONSE_OK:
|
||||||
return None
|
return None
|
||||||
artists = response.get('searchResult3').get('artist')
|
return response.get('searchResult2')
|
||||||
if artists is not None:
|
|
||||||
return [self.raw_artist_to_artist(artist) for artist in artists]
|
|
||||||
return None
|
|
||||||
|
|
||||||
def find_tracks_by_name(self, track_name):
|
def find_as_search_result(self, query, exclude_artists=False, exclude_albums=False, exclude_songs=False):
|
||||||
response = self.connection.search3(track_name, 0, 0, 0, 0, MAX_SEARCH_RESULTS, 0)
|
result = self.find_raw(query)
|
||||||
if response.get('status') != RESPONSE_OK:
|
return SearchResult(
|
||||||
return None
|
uri=uri.get_search_uri(query),
|
||||||
tracks = response.get('searchResult3').get('song')
|
artists=[self.raw_artist_to_artist(artist) for artist in result.get('artist') or []],
|
||||||
if tracks is not None:
|
albums=[self.raw_album_to_album(album) for album in result.get('album') or []],
|
||||||
return [self.raw_song_to_track(track) for track in tracks]
|
tracks=[self.raw_song_to_track(song) for song in result.get('song') or []])
|
||||||
return None
|
|
||||||
|
|
||||||
def find_albums_by_name(self, album_name):
|
|
||||||
response = self.connection.search3(album_name, 0, 0, MAX_SEARCH_RESULTS, 0, 0, 0)
|
|
||||||
if response.get('status') != RESPONSE_OK:
|
|
||||||
return None
|
|
||||||
albums = response.get('searchResult3').get('album')
|
|
||||||
if albums is not None:
|
|
||||||
return [self.raw_album_to_album(album) for album in albums]
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_raw_artists(self):
|
def get_raw_artists(self):
|
||||||
response = self.connection.getIndexes()
|
response = self.connection.getIndexes()
|
||||||
|
@ -74,27 +66,40 @@ class SubsonicApi():
|
||||||
return None
|
return None
|
||||||
letters = response.get('indexes').get('index')
|
letters = response.get('indexes').get('index')
|
||||||
if letters is not None:
|
if letters is not None:
|
||||||
return [artist for letter in letters for artist in letter.get('artist')]
|
artists = [artist for letter in letters for artist in letter.get('artist')]
|
||||||
|
return artists
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def find_song_by_id(self, song_id):
|
def get_song_by_id(self, song_id):
|
||||||
response = self.connection.getSong(song_id)
|
response = self.connection.getSong(song_id)
|
||||||
if response.get('status') != RESPONSE_OK:
|
if response.get('status') != RESPONSE_OK:
|
||||||
return None
|
return None
|
||||||
return self.raw_song_to_track(response.get('song')) if response.get('song') is not None else None
|
return self.raw_song_to_track(response.get('song')) if response.get('song') is not None else None
|
||||||
|
|
||||||
def find_album_by_id(self, album_id):
|
def get_album_by_id(self, album_id):
|
||||||
response = self.connection.getAlbum(album_id)
|
response = self.connection.getAlbum(album_id)
|
||||||
if response.get('status') != RESPONSE_OK:
|
if response.get('status') != RESPONSE_OK:
|
||||||
return None
|
return None
|
||||||
return self.raw_album_to_album(response.get('album')) if response.get('album') is not None else None
|
return self.raw_album_to_album(response.get('album')) if response.get('album') is not None else None
|
||||||
|
|
||||||
def find_artist_by_id(self, artist_id):
|
def get_artist_by_id(self, artist_id):
|
||||||
response = self.connection.getArtist(artist_id)
|
response = self.connection.getArtist(artist_id)
|
||||||
if response.get('status') != RESPONSE_OK:
|
if response.get('status') != RESPONSE_OK:
|
||||||
return None
|
return None
|
||||||
return self.raw_artist_to_artist(response.get('artist')) if response.get('artist') is not None else None
|
return self.raw_artist_to_artist(response.get('artist')) if response.get('artist') is not None else None
|
||||||
|
|
||||||
|
def get_raw_playlists(self):
|
||||||
|
response = self.connection.getPlaylists()
|
||||||
|
if response.get('status') != RESPONSE_OK:
|
||||||
|
return None
|
||||||
|
return response.get('playlists').get('playlist')
|
||||||
|
|
||||||
|
def get_raw_playlist(self, playlist_id):
|
||||||
|
response = self.connection.getPlaylist(playlist_id)
|
||||||
|
if response.get('status') != RESPONSE_OK:
|
||||||
|
return None
|
||||||
|
return response.get('playlist')
|
||||||
|
|
||||||
def get_raw_dir(self, parent_id):
|
def get_raw_dir(self, parent_id):
|
||||||
response = self.connection.getMusicDirectory(parent_id)
|
response = self.connection.getMusicDirectory(parent_id)
|
||||||
if response.get('status') != RESPONSE_OK:
|
if response.get('status') != RESPONSE_OK:
|
||||||
|
@ -111,34 +116,42 @@ class SubsonicApi():
|
||||||
return self.get_raw_dir(album_id)
|
return self.get_raw_dir(album_id)
|
||||||
|
|
||||||
def get_albums_as_refs(self, artist_id):
|
def get_albums_as_refs(self, artist_id):
|
||||||
return sorted([self.raw_album_to_ref(album) for album in self.get_raw_albums(artist_id)], key=ref_sort_key)
|
return [self.raw_album_to_ref(album) for album in self.get_raw_albums(artist_id)]
|
||||||
|
|
||||||
def get_albums_as_albums(self, artist_id):
|
def get_albums_as_albums(self, artist_id):
|
||||||
return sorted([self.raw_album_to_album(album) for album in self.get_raw_albums(artist_id)], key=ref_sort_key)
|
return [self.raw_album_to_album(album) for album in self.get_raw_albums(artist_id)]
|
||||||
|
|
||||||
def get_songs_as_refs(self, album_id):
|
def get_songs_as_refs(self, album_id):
|
||||||
return sorted([self.raw_song_to_ref(song) for song in self.get_raw_songs(album_id)], key=ref_sort_key)
|
return [self.raw_song_to_ref(song) for song in self.get_raw_songs(album_id)]
|
||||||
|
|
||||||
def get_songs_as_tracks(self, album_id):
|
def get_songs_as_tracks(self, album_id):
|
||||||
return sorted([self.raw_song_to_track(song) for song in self.get_raw_songs(album_id)], key=ref_sort_key)
|
return [self.raw_song_to_track(song) for song in self.get_raw_songs(album_id)]
|
||||||
|
|
||||||
def get_artists_as_refs(self):
|
def get_artists_as_refs(self):
|
||||||
return sorted([self.raw_artist_to_ref(artist) for artist in self.get_raw_artists()], key=ref_sort_key)
|
return [self.raw_artist_to_ref(artist) for artist in self.get_raw_artists()]
|
||||||
|
|
||||||
def get_artists_as_artists(self):
|
def get_artists_as_artists(self):
|
||||||
return sorted([self.raw_artist_to_artist(artist) for artist in self.get_raw_artists()], key=lambda artist:artist.name)
|
return [self.raw_artist_to_artist(artist) for artist in self.get_raw_artists()]
|
||||||
|
|
||||||
|
def get_playlists_as_refs(self):
|
||||||
|
return [self.raw_playlist_to_ref(playlist) for playlist in self.get_raw_playlists()]
|
||||||
|
|
||||||
|
def get_playlists_as_playlists(self):
|
||||||
|
return [self.raw_playlist_to_playlist(playlist) for playlist in self.get_raw_playlists()]
|
||||||
|
|
||||||
|
def get_playlist_as_playlist(self, playlist_id):
|
||||||
|
return self.raw_playlist_to_playlist(self.get_raw_playlist(playlist_id))
|
||||||
|
|
||||||
|
def get_playlist_as_songs_as_refs(self, playlist_id):
|
||||||
|
playlist = self.get_raw_playlist(playlist_id)
|
||||||
|
return [self.raw_song_to_ref(song) for song in playlist.get('entry')]
|
||||||
|
|
||||||
def raw_song_to_ref(self, song):
|
def raw_song_to_ref(self, song):
|
||||||
return Ref(
|
return Ref.track(
|
||||||
name=song.get('title') or UNKNOWN_SONG,
|
name=song.get('title') or UNKNOWN_SONG,
|
||||||
uri=uri.get_song_uri(song.get('id')),
|
uri=uri.get_song_uri(song.get('id')))
|
||||||
type=Ref.TRACK)
|
|
||||||
|
|
||||||
def raw_song_to_track(self, song):
|
def raw_song_to_track(self, song):
|
||||||
album_name = song.get('album')
|
|
||||||
album = self.find_albums_by_name(album_name)[0] if album_name is not None else None
|
|
||||||
artist_name = song.get('artist')
|
|
||||||
artist = self.find_artists_by_name(artist_name)[0] if artist_name is not None else None
|
|
||||||
return Track(
|
return Track(
|
||||||
name=song.get('title') or UNKNOWN_SONG,
|
name=song.get('title') or UNKNOWN_SONG,
|
||||||
uri=uri.get_song_uri(song.get('id')),
|
uri=uri.get_song_uri(song.get('id')),
|
||||||
|
@ -148,32 +161,44 @@ class SubsonicApi():
|
||||||
genre=song.get('genre'),
|
genre=song.get('genre'),
|
||||||
length=int(song.get('duration')) * 1000 if song.get('duration') else None,
|
length=int(song.get('duration')) * 1000 if song.get('duration') else None,
|
||||||
disc_no=int(song.get('discNumber')) if song.get('discNumber') else None,
|
disc_no=int(song.get('discNumber')) if song.get('discNumber') else None,
|
||||||
artists=[artist] if artist is not None else None,
|
artists=[Artist(
|
||||||
album=album
|
name=song.get('artist'),
|
||||||
)
|
uri=uri.get_artist_uri(song.get('artistId')))],
|
||||||
|
album=Album(
|
||||||
|
name=song.get('album'),
|
||||||
|
uri=uri.get_album_uri('albumId')))
|
||||||
def raw_album_to_ref(self, album):
|
def raw_album_to_ref(self, album):
|
||||||
return Ref(
|
return Ref.album(
|
||||||
name=album.get('title') or album.get('name') or UNKNOWN_ALBUM,
|
name=album.get('title') or album.get('name') or UNKNOWN_ALBUM,
|
||||||
uri=uri.get_album_uri(album.get('id')),
|
uri=uri.get_album_uri(album.get('id')))
|
||||||
type=Ref.ALBUM)
|
|
||||||
|
|
||||||
def raw_album_to_album(self, album):
|
def raw_album_to_album(self, album):
|
||||||
artist_name = album.get('artist')
|
|
||||||
artist = self.find_artists_by_name(artist_name)[0] if artist_name is not None else None
|
|
||||||
return Album(
|
return Album(
|
||||||
name=album.get('title') or album.get('name') or UNKNOWN_ALBUM,
|
name=album.get('title') or album.get('name') or UNKNOWN_ALBUM,
|
||||||
uri=uri.get_album_uri(album.get('id')),
|
uri=uri.get_album_uri(album.get('id')),
|
||||||
artists=[artist]
|
artists=[Artist(
|
||||||
)
|
name=album.get('artist'),
|
||||||
|
uri=uri.get_artist_uri(album.get('artistId')))])
|
||||||
|
|
||||||
def raw_artist_to_ref(self, artist):
|
def raw_artist_to_ref(self, artist):
|
||||||
return Ref(
|
return Ref.artist(
|
||||||
name=artist.get('name') or UNKNOWN_ARTIST,
|
name=artist.get('name') or UNKNOWN_ARTIST,
|
||||||
uri=uri.get_artist_uri(artist.get('id')),
|
uri=uri.get_artist_uri(artist.get('id')))
|
||||||
type=Ref.ARTIST)
|
|
||||||
|
|
||||||
def raw_artist_to_artist(self, artist):
|
def raw_artist_to_artist(self, artist):
|
||||||
return Artist(
|
return Artist(
|
||||||
name=artist.get('name') or UNKNOWN_ARTIST,
|
name=artist.get('name') or UNKNOWN_ARTIST,
|
||||||
uri=uri.get_artist_uri(artist.get('id'))
|
uri=uri.get_artist_uri(artist.get('id')))
|
||||||
)
|
|
||||||
|
def raw_playlist_to_playlist(self, playlist):
|
||||||
|
entries = playlist.get('entry')
|
||||||
|
tracks = [self.raw_song_to_track(song) for song in entries] if entries is not None else None
|
||||||
|
return Playlist(
|
||||||
|
uri=uri.get_playlist_uri(playlist.get('id')),
|
||||||
|
name=playlist.get('name'),
|
||||||
|
tracks=tracks)
|
||||||
|
|
||||||
|
def raw_playlist_to_ref(self, playlist):
|
||||||
|
return Ref.playlist(
|
||||||
|
uri=uri.get_playlist_uri(playlist.get('id')),
|
||||||
|
name=playlist.get('name'))
|
||||||
|
|
|
@ -6,6 +6,7 @@ PLAYLIST = 'playlist'
|
||||||
ALBUM = 'album'
|
ALBUM = 'album'
|
||||||
PREFIX = 'subidy'
|
PREFIX = 'subidy'
|
||||||
ROOT = 'root'
|
ROOT = 'root'
|
||||||
|
SEARCH = 'search'
|
||||||
|
|
||||||
ROOT_URI = '%s:%s' % (PREFIX, ROOT)
|
ROOT_URI = '%s:%s' % (PREFIX, ROOT)
|
||||||
|
|
||||||
|
@ -17,6 +18,9 @@ def is_type_result_valid(result):
|
||||||
def is_id_result_valid(result, type):
|
def is_id_result_valid(result, type):
|
||||||
return is_type_result_valid(result) and result.group(1) == PREFIX and result.group(2) == type
|
return is_type_result_valid(result) and result.group(1) == PREFIX and result.group(2) == type
|
||||||
|
|
||||||
|
def is_uri(uri):
|
||||||
|
return regex.match(uri) is not None
|
||||||
|
|
||||||
def get_song_id(uri):
|
def get_song_id(uri):
|
||||||
result = regex.match(uri)
|
result = regex.match(uri)
|
||||||
if not is_id_result_valid(result, SONG):
|
if not is_id_result_valid(result, SONG):
|
||||||
|
@ -58,3 +62,9 @@ def get_album_uri(id):
|
||||||
|
|
||||||
def get_song_uri(id):
|
def get_song_uri(id):
|
||||||
return get_type_uri(SONG, id)
|
return get_type_uri(SONG, id)
|
||||||
|
|
||||||
|
def get_playlist_uri(id):
|
||||||
|
return get_type_uri(PLAYLIST, id)
|
||||||
|
|
||||||
|
def get_search_uri(query):
|
||||||
|
return get_type_uri(SEARCH, query)
|
||||||
|
|
4
setup.py
4
setup.py
|
@ -1,7 +1,7 @@
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from setuptools import setup, find_packages
|
from setuptools import setup, get_packages
|
||||||
|
|
||||||
|
|
||||||
def get_version(filename):
|
def get_version(filename):
|
||||||
|
@ -18,7 +18,7 @@ setup(
|
||||||
author_email='fgnodtke@cronosx.de',
|
author_email='fgnodtke@cronosx.de',
|
||||||
description='Improved Subsonic extension for Mopidy',
|
description='Improved Subsonic extension for Mopidy',
|
||||||
long_description=open('README.md').read(),
|
long_description=open('README.md').read(),
|
||||||
packages=find_packages(exclude=['tests', 'tests.*']),
|
packages=get_packages(exclude=['tests', 'tests.*']),
|
||||||
zip_safe=False,
|
zip_safe=False,
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=[
|
install_requires=[
|
||||||
|
|
Loading…
Add table
Reference in a new issue