diff --git a/CHANGELOG.md b/CHANGELOG.md index ed21b44..4f82440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## 2.3a0 (WIP) +- Model 支持 `create_by_display` 工厂函数 + - 给 BaseModel 添加 `__getattribute__` 方法 + - 给 NBaseModel 和 XBaseModel 移除 `__getattribute__` 方法和 + `_detail_fields` 类属性 +- QQ 音乐 SongModel 支持 get 方法 + ## 2.2 (2018-12-28) - 发一个 2.2 的正式版(经过测试,相对稳定) @@ -25,7 +32,6 @@ ## 2.2a0 (2018-11-06) - 给 library 添加 `list_song_standby` 接口 - **BREAKING CHANGE**: 修改本地音乐 ID 计算方法 - ## 2.1 (2018-10-08) - 修复 XUserModel 的问题 - 完善接口文档 diff --git a/examples/model_display.py b/examples/model_display.py new file mode 100755 index 0000000..fdff53f --- /dev/null +++ b/examples/model_display.py @@ -0,0 +1,29 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging + +from fuocore.netease.models import NSongModel + +logging.basicConfig() +logger = logging.getLogger('fuocore') +logger.setLevel(logging.DEBUG) + + +def test_model_display(): + song = NSongModel.create_by_display( + identifier=254548, + title='成全', + artists_name='刘若英') + assert song.album_name_display == '' + assert song.title_display == '成全' + print(song.url, song.title) + assert song.album_name_display != '' + + +def main(): + test_model_display() + + +if __name__ == '__main__': + main() diff --git a/fuocore/__init__.py b/fuocore/__init__.py index 9414cde..dd8e540 100644 --- a/fuocore/__init__.py +++ b/fuocore/__init__.py @@ -9,7 +9,7 @@ from .library import Library # noqa -__version__ = '2.2' +__version__ = '2.3a0' __all__ = [ diff --git a/fuocore/local/models.py b/fuocore/local/models.py index 0c44a8e..256a830 100644 --- a/fuocore/local/models.py +++ b/fuocore/local/models.py @@ -20,25 +20,15 @@ class Meta: allow_get = True provider = provider - def __getattribute__(self, name): - cls = type(self) - value = object.__getattribute__(self, name) - if name in cls._detail_fields and value is None: - logger.debug('Field %s value is None, get model detail first.' % name) - obj = cls.get(self.identifier) - for field in cls._detail_fields: - setattr(self, field, getattr(obj, field)) - value = object.__getattribute__(self, name) - elif name in cls._detail_fields and not value: - logger.debug('Field %s value is not None, but is %s' % (name, value)) - return value - class LSongModel(SongModel, LBaseModel): + class Meta: + fields = ('disc', 'genre', 'date', 'track', 'cover', 'desc') + fields_no_get = ('lyric', ) @classmethod def get(cls, identifier): - return cls.meta.provider.library._songs.get(identifier) + return cls.meta.provider.library.get_song(identifier) @classmethod def list(cls, identifier_list): @@ -50,7 +40,7 @@ class LAlbumModel(AlbumModel, LBaseModel): @classmethod def get(cls, identifier): - return cls.meta.provider.library._albums.get(identifier) + return cls.meta.provider.library.get_album(identifier) class LArtistModel(ArtistModel, LBaseModel): @@ -58,7 +48,7 @@ class LArtistModel(ArtistModel, LBaseModel): @classmethod def get(cls, identifier): - return cls.meta.provider.library._artists.get(identifier) + return cls.meta.provider.library.get_artist(identifier) class LSearchModel(SearchModel, LBaseModel): diff --git a/fuocore/local/provider.py b/fuocore/local/provider.py index 1d54676..3c61279 100644 --- a/fuocore/local/provider.py +++ b/fuocore/local/provider.py @@ -1,21 +1,22 @@ # -*- coding: utf-8 -*- - +# pylint: disable=wrong-import-position """ TODO: 这个模块中目前逻辑非常多,包括音乐目录扫描、音乐库的构建等小部分, 这些小部分理论都可以从中拆除。 """ +import base64 import logging -import pickle import os +import re from fuzzywuzzy import process from marshmallow.exceptions import ValidationError from mutagen import MutagenError from mutagen.mp3 import EasyMP3 from mutagen.easymp4 import EasyMP4 - from fuocore.provider import AbstractProvider +from fuocore.utils import elfhash from fuocore.utils import log_exectime @@ -41,7 +42,29 @@ def scan_directory(directory, exts=None, depth=2): return media_files -def create_song(fpath): +def gen_id(s): + return str(elfhash(base64.b64encode(bytes(s, 'utf-8')))) + + +def create_artist(identifier, name): + return LArtistModel(identifier=identifier, + name=name, + songs=[], + albums=[], + desc='', + cover='',) + + +def create_album(identifier, name): + return LAlbumModel(identifier=identifier, + name=name, + songs=[], + artists=[], + desc='', + cover='',) + + +def add_song(fpath, g_songs, g_artists, g_albums): """ parse music file metadata with Easymp3 and return a song model. @@ -55,7 +78,6 @@ def create_song(fpath): logger.exception('Mutagen parse metadata failed, ignore.') return None - schema = EasyMP3MetadataSongSchema(strict=True) metadata_dict = dict(metadata) for key in metadata.keys(): metadata_dict[key] = metadata_dict[key][0] @@ -66,123 +88,132 @@ def create_song(fpath): url=fpath, duration=metadata.info.length * 1000 # milesecond )) + schema = EasyMP3MetadataSongSchema(strict=True) try: - song, _ = schema.load(metadata_dict) + data, _ = schema.load(metadata_dict) except ValidationError: logger.exeception('解析音乐文件({}) 元数据失败'.format(fpath)) - return song + return + + # NOTE: use {title}-{artists_name}-{album_name} as song identifier + title = data['title'] + album_name = data['album_name'] + artist_name_list = [ + name.strip() + for name in re.split(r'[,&]', data['artists_name'])] + artists_name = ','.join(artist_name_list) + duration = data['duration'] + album_artist_name = data['album_artist_name'] + + # 生成 song model + song_id_str = ' - '.join([title, artists_name, album_name, str(int(duration))]) + song_id = gen_id(song_id_str) + if song_id not in g_songs: + # 剩下 album, lyric 三个字段没有初始化 + song = LSongModel(identifier=song_id, + artists=[], + title=title, + url=fpath, + duration=duration, + comments=[], + # 下面这些字段不向外暴露 + genre=data['genre'], + cover=data['cover'], + date=data['date'], + desc=data['desc'], + disc=data['disc'], + track=data['track']) + g_songs[song_id] = song + else: + song = g_songs[song_id] + logger.warning('duplicate song: %s %s', song.url, fpath) + return + + # 生成 album artist model + album_artist_id = gen_id(album_artist_name) + if album_artist_id not in g_artists: + album_artist = create_artist(album_artist_id, album_artist_name) + g_artists[album_artist_id] = album_artist + else: + album_artist = g_artists[album_artist_id] + + # 生成 album model + album_id_str = album_name + album_artist_name + album_id = gen_id(album_id_str) + if album_id not in g_albums: + album = create_album(album_id, album_name) + g_albums[album_id] = album + else: + album = g_albums[album_id] + + # 处理专辑的歌手信息和歌曲信息,专辑歌手的专辑列表信息 + if album not in album_artist.albums: + album_artist.albums.append(album) + if album_artist not in album.artists: + album.artists.append(album_artist) + if song not in album.songs: + album.songs.append(song) + + # 处理歌曲的歌手和专辑信息,以及歌手的歌曲列表 + song.album = album + for artist_name in artist_name_list: + artist_id = gen_id(artist_name) + if artist_id in g_artists: + artist = g_artists[artist_id] + else: + artist = create_artist(identifier=artist_id, name=artist_name) + g_artists[artist_id] = artist + if artist not in song.artists: + song.artists.append(artist) + if song not in artist.songs: + artist.songs.append(song) + + +class Library: + DEFAULT_MUSIC_FOLDER = os.path.expanduser('~') + '/Music' + def __init__(self): + self._songs = {} + self._albums = {} + self._artists = {} -class Scanner: - """本地歌曲扫描器""" + def list_songs(self): + return list(self._songs.values()) - DEFAULT_MUSIC_FOLDER = os.path.expanduser('~') + '/Music' + def get_song(self, identifier): + return self._songs.get(identifier) - def __init__(self, paths=None, depth=2): - self._songs = [] - self.depth = depth - self.paths = paths or [Scanner.DEFAULT_MUSIC_FOLDER] + def get_album(self, identifier): + return self._albums.get(identifier) - @property - def songs(self): - return self._songs + def get_artist(self, identifier): + return self._artists.get(identifier) @log_exectime - def run(self): + def scan(self, paths=None, depth=2): """scan media files in all paths """ song_exts = ['mp3', 'ogg', 'wma', 'm4a'] exts = song_exts - depth = self.depth if self.depth <= 3 else 3 + paths = paths or [Library.DEFAULT_MUSIC_FOLDER] + depth = depth if depth <= 3 else 3 media_files = [] - for directory in self.paths: + for directory in paths: logger.debug('正在扫描目录(%s)...', directory) media_files.extend(scan_directory(directory, exts, depth)) + logger.info('共扫描到 %d 个音乐文件,准备将其录入本地音乐库', len(media_files)) - self._songs = [] for fpath in media_files: - song = create_song(fpath) - if song is not None: - self._songs.append(song) - else: - logger.warning('%s can not be recognized', fpath) - logger.debug('扫描到 %d 首歌曲', len(self._songs)) - + add_song(fpath, self._songs, self._artists, self._albums) + logger.info('录入本地音乐库完毕') -class DataBase: - def __init__(self): - #: identifier song map: {id: song, ...} - self._songs = dict() - - #: identifier album map: {id: album, ...} - self._albums = dict() - - #: identifier artist map: {id: artist, ...} - self._artists = dict() - - @property - def songs(self): - return self._songs.values() - - @property - def albums(self): - return self._albums.values() - - @property - def artists(self): - return self._artists.values() - - def run(self, songs): - self._songs.clear() - self._albums.clear() - self._artists.clear() - - self.setup_library(songs) - self.analyze_library() - - def setup_library(self, scanner_songs): - for song in scanner_songs: - if song.identifier in self._songs: - continue - self._songs[song.identifier] = song - - if song.album is not None: - album = song.album - if album.identifier not in self._albums: - album_data = {'identifier': album.identifier, - 'name': album.name, - 'artists_name': album.artists[0].name if album.artists else '', - 'songs': []} - self._albums[album.identifier], _ = LocalAlbumSchema(strict=True).load(album_data) - self._albums[album.identifier].songs.append(song) - - if song.artists is not None: - for artist in song.artists: - if artist.identifier not in self._artists: - artist_data = {'identifier': artist.identifier, - 'name': artist.name, - 'songs': [], - 'albums': []} - self._artists[artist.identifier], _ = LocalArtistSchema(strict=True).load(artist_data) - self._artists[artist.identifier].songs.append(song) - - def analyze_library(self): + def sortout(self): for album in self._albums.values(): try: album.songs.sort(key=lambda x: (int(x.disc.split('/')[0]), int(x.track.split('/')[0]))) except Exception as e: logger.exception('Sort album songs failed.') - if album.artists is not None: - album_artist = album.artists[0] - if album_artist.identifier not in self._artists: - album_artist_data = {'identifier': album_artist.identifier, - 'name': album_artist.name, - 'songs': [], - 'albums': []} - self._artists[album_artist.identifier], _ = LocalArtistSchema(strict=True).load(album_artist_data) - self._artists[album_artist.identifier].albums.append(album) - for artist in self._artists.values(): if artist.albums: artist.albums.sort(key=lambda x: (x.songs[0].date is None, x.songs[0].date), reverse=True) @@ -195,19 +226,11 @@ class LocalProvider(AbstractProvider): def __init__(self): super().__init__() - self.library = DataBase() - self._songs = [] - self._albums = [] - self._artists = [] + self.library = Library() def scan(self, paths=None, depth=3): - scanner = Scanner(paths or [], depth=depth) - scanner.run() - - self.library.run(scanner.songs) - self._songs = list(self.library.songs) - self._albums = list(self.library.albums) - self._artists = list(self.library.artists) + self.library.scan(paths, depth) + self.library.sortout() @property def identifier(self): @@ -219,15 +242,7 @@ def name(self): @property def songs(self): - return self._songs - - @property - def artists(self): - return self._artists - - @property - def albums(self): - return self._albums + return self.library.list_songs() @log_exectime def search(self, keyword, **kwargs): @@ -248,7 +263,10 @@ def search(self, keyword, **kwargs): provider = LocalProvider() -from .schemas import LocalAlbumSchema -from .schemas import LocalArtistSchema from .schemas import EasyMP3MetadataSongSchema -from .models import LSearchModel +from .models import ( + LSearchModel, + LSongModel, + LAlbumModel, + LArtistModel, +) diff --git a/fuocore/local/schemas.py b/fuocore/local/schemas.py index 5238dd4..dc88bf4 100644 --- a/fuocore/local/schemas.py +++ b/fuocore/local/schemas.py @@ -1,106 +1,26 @@ # -*- coding: utf-8 -*- -import re -import base64 +from marshmallow import Schema, fields -from marshmallow import Schema, fields, post_load -from fuocore.utils import elfhash +DEFAULT_TITLE = DEFAULT_ARTIST_NAME = DEFAULT_ALBUM_NAME = 'Unknown' -class BaseSchema(Schema): - identifier = fields.Field(required=True, missing=None) - desc = fields.Str() - - -class LocalArtistSchema(BaseSchema): - # TODO: 添加一个 alias 字段? - name = fields.Str(required=True) - cover = fields.Str() # NOTE: 可能需要单独一个 Schema - songs = fields.List(fields.Nested('LocalSongSchema'), missing=None) - albums = fields.List(fields.Nested('LocalAlbumSchema'), missing=None) - - @post_load - def create_model(self, data): - if data['identifier'] is None: - data['identifier'] = str(elfhash(base64.b64encode(bytes(data['name'], 'utf-8')))) - return LArtistModel(**data) - - -class LocalAlbumSchema(BaseSchema): - name = fields.Str(required=True) - img = fields.Str() - songs = fields.List(fields.Nested('LocalSongSchema'), missing=None) - artists = fields.List(fields.Nested(LocalArtistSchema), missing=None) - - artists_name = fields.Str() - - @post_load - def create_model(self, data): - if data['identifier'] is None: - identifier_str = '{} - {}'.format(data['name'], data['artists_name']) - data['identifier'] = str(elfhash(base64.b64encode(bytes(identifier_str, 'utf-8')))) - album = LAlbumModel(**data) - if album.artists is None and data['artists_name']: - album_artist, _ = LocalArtistSchema(strict=True).load({'name': data['artists_name']}) - album.artists = [album_artist] - return album - - -class LocalSongSchema(BaseSchema): - title = fields.Str(required=True) - url = fields.Str(required=True) - duration = fields.Float(required=True) # mileseconds - album = fields.Nested(LocalAlbumSchema, missing=None) - artists = fields.List(fields.Nested(LocalArtistSchema), missing=None) - - @post_load - def create_model(self, data): - return LSongModel(**data) - class EasyMP3MetadataSongSchema(Schema): """EasyMP3 metadata""" url = fields.Str(required=True) duration = fields.Float(required=True) - title = fields.Str(required=True, missing='Unknown') - artists_name = fields.Str(required=True, load_from='artist', missing='') - album_name = fields.Str(required=True, load_from='album', missing='') - album_artist_name = fields.Str(required=True, load_from='albumartist', missing='') - track = fields.Str(load_from='tracknumber') - disc = fields.Str(load_from='discnumber') - date = fields.Str() - genre = fields.Str() - - @post_load - def create_model(self, data): - # NOTE: use {title}-{artists_name}-{album_name} as song identifier - identifier_str = '{} - {} - {} - {}'.format(data['title'], data['artists_name'], data['album_name'], - data['duration']) - data['identifier'] = str(elfhash(base64.b64encode(bytes(identifier_str, 'utf-8')))) - song, _ = LocalSongSchema(strict=True).load(data) - - if song.album is None and data['album_name']: - album_data = {'name': data['album_name'], - 'artists_name': data['album_artist_name']} - song.album, _ = LocalAlbumSchema(strict=True).load(album_data) - - if song.artists is None and data['artists_name']: - song.artists = [] - artist_names = [artist.strip() for artist in re.split(r'[,&]', data['artists_name'])] - for artist_name in artist_names: - artist_data = {'name': artist_name} - artist, _ = LocalArtistSchema(strict=True).load(artist_data) - song.artists.append(artist) - - song.genre = data.get('genre', None) - if song.album is not None: - song.disc = data.get('disc', '1/1') - song.track = data.get('track', '1/1') - song.date = data.get('date', None) - return song - - -from .models import ( - LAlbumModel, - LArtistModel, - LSongModel, -) + title = fields.Str(required=True, missing=DEFAULT_TITLE) + artists_name = fields.Str(required=True, load_from='artist', + missing=DEFAULT_ARTIST_NAME) + album_name = fields.Str(required=True, load_from='album', + missing=DEFAULT_ALBUM_NAME) + album_artist_name = fields.Str(required=True, load_from='albumartist', + missing=DEFAULT_ARTIST_NAME) + track = fields.Str(load_from='tracknumber', missing='1/1') + disc = fields.Str(load_from='discnumber', missing='1/1') + date = fields.Str(missing='') + genre = fields.Str(missing='') + + # 本地歌曲目前都不支持描述和封面,将它们设置为空字符串 + desc = fields.Str(missing='') + cover = fields.Str(missing='') diff --git a/fuocore/models.py b/fuocore/models.py index a21aa35..2f2c9d9 100644 --- a/fuocore/models.py +++ b/fuocore/models.py @@ -4,10 +4,32 @@ fuocore.models ~~~~~~~~~~~~~~ -This modules defines several music models. +这个模块定义了音乐资源的模型,如歌曲模型: ``SongModel`` , 歌手模型: ``ArtistModel`` 。 +它们都类似这样:: + + class XyzModel(BaseModel): + class Meta: + model_type = ModelType.xyz + fields = ['a', 'b', 'c'] + + @property + def ab(self): + return self.a + self.b + +同时,为了减少实现这些模型时带来的重复代码,这里还实现了: + +- ModelMeta: Model 元类,进行一些黑科技处理:比如解析 Model Meta 类 +- ModelMetadata: Model meta 属性对应的类 +- BaseModel: 基类 + +ModelMetadata, ModelMeta, BaseModel 几个类是互相依赖的。 """ from enum import IntEnum +import logging + + +logger = logging.getLogger(__name__) class ModelType(IntEnum): @@ -22,11 +44,20 @@ class ModelType(IntEnum): user = 17 +class ModelStage(IntEnum): + """Model 所处的阶段,有大小关系""" + display = 4 + inited = 8 + gotten = 16 + + class ModelMetadata(object): def __init__(self, model_type=ModelType.dummy.value, provider=None, fields=None, + fields_display=None, + fields_no_get=None, allow_get=False, allow_batch=False, **kwargs): @@ -37,34 +68,54 @@ def __init__(self, """ self.model_type = model_type self.provider = provider - self.fields = fields + self.fields = fields or [] + self.fields_display = fields_display or [] + self.fields_no_get = fields_no_get or [] self.allow_get = allow_get self.allow_batch = allow_batch for key, value in kwargs.items(): setattr(self, key, value) +class display_property: + """Model 的展示字段的描述器""" + def __init__(self, name): + #: display 属性对应的真正属性的名字 + self.name_real = name + #: 用来存储值的属性名 + self.store_pname = '_display_store_' + name + + def __get__(self, instance, _=None): + if instance.stage >= ModelStage.inited: + return getattr(instance, self.name_real) + return getattr(instance, self.store_pname, '') + + def __set__(self, instance, value): + setattr(instance, self.store_pname, value) + + class ModelMeta(type): def __new__(cls, name, bases, attrs): - # get all meta + # 获取 Model 当前以及父类中的 Meta 信息 + # 如果 Meta 中相同字段的属性,子类的值可以覆盖父类的 _metas = [] for base in bases: base_meta = getattr(base, '_meta', None) if base_meta is not None: _metas.append(base_meta) - - # similar with djang/peewee model meta Meta = attrs.pop('Meta', None) if Meta: _metas.append(Meta) - fields = list() - meta_kv = {} + kind_fields_map = {'fields': [], + 'fields_display': [], + 'fields_no_get': []} + meta_kv = {} # 实例化 ModelMetadata 的 kv 对 for _meta in _metas: - inherited_fields = getattr(_meta, 'fields', []) - fields.extend(inherited_fields) + for kind, fields in kind_fields_map.items(): + fields.extend(getattr(_meta, kind, [])) for k, v in _meta.__dict__.items(): - if k.startswith('_') or k in ('fields', ): + if k.startswith('_') or k in kind_fields_map: continue if k == 'model_type': if ModelType(v) != ModelType.dummy: @@ -79,12 +130,21 @@ def __new__(cls, name, bases, attrs): model_type = meta_kv.pop('model_type', ModelType.dummy.value) if provider and ModelType(model_type) != ModelType.dummy: provider.set_model_cls(model_type, klass) - fields = list(set(fields)) + + fields_all = list(set(kind_fields_map['fields'])) + fields_display = list(set(kind_fields_map['fields_display'])) + fields_no_get = list(set(kind_fields_map['fields_no_get'])) + + for field in fields_display: + setattr(klass, field + '_display', display_property(field)) # DEPRECATED attribute _meta + # TODO: remove this in verion 2.3 klass._meta = ModelMetadata(model_type=model_type, provider=provider, - fields=fields, + fields=fields_all, + fields_display=fields_display, + fields_no_get=fields_no_get, **meta_kv) klass.source = provider.identifier if provider is not None else None # use meta attribute instead of _meta @@ -92,17 +152,19 @@ def __new__(cls, name, bases, attrs): return klass -class Model(object, metaclass=ModelMeta): +class Model(metaclass=ModelMeta): """base class for data models + Usage:: class User(Model): class Meta: fields = ['name', 'title'] + user = UserModel(name='xxx') assert user.name == 'xxx' user2 = UserModel(user) - assert user2.name = 'xxx' + assert user2.name == 'xxx' """ def __init__(self, obj=None, **kwargs): @@ -118,7 +180,6 @@ class BaseModel(Model): """Base model for music resource. :param identifier: model object identifier, unique in each provider - :param source: model object provider identifier :cvar allow_get: meta var, whether model has a valid get method :cvar allow_list: meta var, whether model has a valid list method @@ -128,23 +189,75 @@ class Meta: allow_get = True allow_list = False model_type = ModelType.dummy.value + + #: Model 所有字段,子类可以通过设置该字段以添加其它字段 fields = ['identifier'] + #: Model 用来展示的字段 + fields_display = [] + + #: 不触发 get 的 Model 字段 + fields_no_get = [] + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.stage = kwargs.get('stage', ModelStage.inited) + def __eq__(self, other): if not isinstance(other, BaseModel): return False return all([other.source == self.source, other.identifier == self.identifier, - other._meta.model_type == self._meta.model_type]) + other.meta.model_type == self.meta.model_type]) + + def __getattribute__(self, name): + """ + 获取 model 某一属性时,如果该属性值为 None 且该属性是 field + 且该属性允许触发 get 方法,这时,我们尝试通过获取 model + 详情来初始化这个字段,于此同时,还会重新给除 identifier + 外的所 fields 重新赋值。 + """ + cls = type(self) + cls_name = cls.__name__ + value = object.__getattribute__(self, name) + if name in cls.meta.fields \ + and name not in cls.meta.fields_no_get \ + and value is None \ + and self.stage < ModelStage.gotten: + if cls.meta.allow_get: + logger.info("Model {} {}'s value is None, try to get detail." + .format(repr(self), name)) + obj = cls.get(self.identifier) + if obj is not None: + for field in cls.meta.fields: + if field in ('identifier', ): + continue + # 这里不能使用 getattr,否则有可能会无限 get + fv = object.__getattribute__(obj, field) + setattr(self, field, fv) + self.stage = ModelStage.gotten + else: + logger.warning('Model {} get return None'.format(cls_name)) + else: + logger.warning("Model {} does't allow get".format(cls_name)) + value = object.__getattribute__(self, name) + return value + + @classmethod + def create_by_display(cls, identifier, **kwargs): + model = cls(identifier=identifier) + model.stage = ModelStage.display + for k, v in kwargs.items(): + if k in cls.meta.fields_display: + setattr(model, k + '_display', v) + return model @classmethod def get(cls, identifier): - """Model get method + """获取 model 详情 - Model should return a valid object if the identifier is available. + 这个方法必须尽量初始化所有字段,确保它们的值不是 None。 """ @classmethod @@ -215,7 +328,8 @@ class Meta: model_type = ModelType.song.value # TODO: 支持低/中/高不同质量的音乐文件 fields = ['album', 'artists', 'lyric', 'comments', 'title', 'url', - 'duration',] + 'duration'] + fields_display = ['title', 'artists_name', 'album_name', 'duration_ms'] @property def artists_name(self): @@ -225,6 +339,13 @@ def artists_name(self): def album_name(self): return self.album.name if self.album is not None else '' + @property + def duration_ms(self): + if self.duration is not None: + seconds = self.duration / 1000 + m, s = seconds / 60, seconds % 60 + return '{:02}:{:02}'.format(int(m), int(s)) + @property def filename(self): return '{} - {}.mp3'.format(self.title, self.artists_name) diff --git a/fuocore/netease/models.py b/fuocore/netease/models.py index ce7ca08..0ea6172 100644 --- a/fuocore/netease/models.py +++ b/fuocore/netease/models.py @@ -22,26 +22,12 @@ class NBaseModel(BaseModel): # FIXME: remove _detail_fields and _api to Meta - _detail_fields = () _api = provider.api class Meta: allow_get = True provider = provider - def __getattribute__(self, name): - cls = type(self) - value = object.__getattribute__(self, name) - if name in cls._detail_fields and value is None: - logger.debug('Field %s value is None, get model detail first.' % name) - obj = cls.get(self.identifier) - for field in cls._detail_fields: - setattr(self, field, getattr(obj, field)) - value = object.__getattribute__(self, name) - elif name in cls._detail_fields and not value: - logger.warning('Field %s value is not None, but is %s' % (name, value)) - return value - class NSongModel(SongModel, NBaseModel): @classmethod @@ -126,7 +112,6 @@ def lyric(self, value): class NAlbumModel(AlbumModel, NBaseModel): - _detail_fields = ('cover', 'songs', 'artists', ) @classmethod def get(cls, identifier): @@ -148,7 +133,6 @@ def desc(self, value): class NArtistModel(ArtistModel, NBaseModel): - _detail_fields = ('songs', 'cover') @classmethod def get(cls, identifier): @@ -170,8 +154,6 @@ def desc(self, value): class NPlaylistModel(PlaylistModel, NBaseModel): - _detail_fields = ('songs', ) - class Meta: fields = ('uid') @@ -207,10 +189,9 @@ class NSearchModel(SearchModel, NBaseModel): class NUserModel(UserModel, NBaseModel): - _detail_fields = ('playlists', 'fav_playlists') - class Meta: fields = ('cookies', ) + fields_no_get = ('cookies', ) @classmethod def get(cls, identifier): diff --git a/fuocore/netease/schemas.py b/fuocore/netease/schemas.py index 5a8a0e1..c3ae263 100644 --- a/fuocore/netease/schemas.py +++ b/fuocore/netease/schemas.py @@ -76,7 +76,7 @@ def create_model(self, data): song.album.cover = None song.album.songs = None if data.get('desc') is None: - data.pop('desc') + data['desc'] = '' return NPlaylistModel(**data) diff --git a/fuocore/qqmusic/api.py b/fuocore/qqmusic/api.py index ab8b0da..74adbe0 100644 --- a/fuocore/qqmusic/api.py +++ b/fuocore/qqmusic/api.py @@ -31,6 +31,7 @@ def __init__(self, timeout=1): } def get_song_detail(self, song_id): + song_id = int(song_id) url = 'http://u.y.qq.com/cgi-bin/musicu.fcg' # 往 payload 添加字段,有可能还可以获取相似歌曲、歌单等 payload = { diff --git a/fuocore/qqmusic/models.py b/fuocore/qqmusic/models.py index e6987f8..5fb1a48 100644 --- a/fuocore/qqmusic/models.py +++ b/fuocore/qqmusic/models.py @@ -15,23 +15,13 @@ class QQBaseModel(BaseModel): _detail_fields = () class Meta: + allow_get = True provider = provider @classmethod def get(cls, identifier): raise NotImplementedError - def __getattribute__(self, name): - cls = type(self) - value = object.__getattribute__(self, name) - if name in cls._detail_fields and not value: - obj = cls.get(self.identifier) - for field in cls._detail_fields: - setattr(self, field, getattr(obj, field)) - value = object.__getattribute__(self, name) - return value - - def _deserialize(data, schema_cls): schema = schema_cls(strict=True) @@ -44,6 +34,12 @@ class QQSongModel(SongModel, QQBaseModel): class Meta: fields = ('mid', ) + @classmethod + def get(cls, identifier): + data = cls._api.get_song_detail(identifier) + song = _deserialize(data, QQSongDetailSchema) + return song + @property def url(self): if self._url is not None: @@ -61,8 +57,6 @@ def url(self, url): class QQAlbumModel(AlbumModel, QQBaseModel): - _detail_fields = ('songs', 'desc') - @classmethod def get(cls, identifier): data_album = cls._api.album_detail(identifier) @@ -70,11 +64,6 @@ def get(cls, identifier): return album class QQArtistModel(ArtistModel, QQBaseModel): - _detail_fields = ('songs', 'desc') - - class Meta: - allow_get = True - @classmethod def get(cls, identifier): data_artist = cls._api.artist_detail(identifier) @@ -103,4 +92,5 @@ def search(keyword, **kwargs): QQArtistSchema, QQAlbumSchema, QQSongSchema, + QQSongDetailSchema, ) # noqa diff --git a/fuocore/qqmusic/schemas.py b/fuocore/qqmusic/schemas.py index 1515bd2..501999c 100644 --- a/fuocore/qqmusic/schemas.py +++ b/fuocore/qqmusic/schemas.py @@ -10,6 +10,15 @@ def create_model(self, data): return QQArtistModel(**data) +class _SongAlbumSchema(Schema): + identifier = fields.Int(load_from='id', required=True) + name = fields.Str(load_from='name', required=True) + + @post_load + def create_model(self, data): + return QQAlbumModel(**data) + + class QQSongSchema(Schema): identifier = fields.Int(load_from='songid', required=True) mid = fields.Str(load_from='songmid', required=True) @@ -75,6 +84,25 @@ def create_model(self, data): return album +class QQSongDetailSchema(Schema): + identifier = fields.Int(load_from='id', required=True) + mid = fields.Str(required=True) + duration = fields.Float(load_from='interval', required=True) + title = fields.Str(load_from='name', required=True) + artists = fields.List(fields.Nested('_SongArtistSchema'), load_from='singer') + album = fields.Nested('_SongArtistSchema', required=True) + + @post_load + def create_model(self, data): + song = QQSongModel(identifier=data['identifier'], + mid=data['mid'], + duration=data['duration'], + title=data['title'], + artists=data.get('artists'), + album=data.get('album'),) + return song + + from .models import ( QQArtistModel, QQSongModel, diff --git a/fuocore/xiami/models.py b/fuocore/xiami/models.py index d0496c9..43ad38e 100644 --- a/fuocore/xiami/models.py +++ b/fuocore/xiami/models.py @@ -18,27 +18,12 @@ class XBaseModel(BaseModel): - # FIXME: remove _detail_fields and _api to Meta - _detail_fields = () _api = provider.api class Meta: allow_get = True provider = provider - def __getattribute__(self, name): - cls = type(self) - value = object.__getattribute__(self, name) - if name in cls._detail_fields and value is None: - logger.debug('Field %s value is None, get model detail first.' % name) - obj = cls.get(self.identifier) - for field in cls._detail_fields: - setattr(self, field, getattr(obj, field)) - value = object.__getattribute__(self, name) - elif name in cls._detail_fields and not value: - logger.warning('Field %s value is not None, but is %s' % (name, value)) - return value - def _deserialize(data, schema_cls): schema = schema_cls(strict=True) @@ -88,7 +73,6 @@ def lyric(self, value): class XAlbumModel(AlbumModel, XBaseModel): - _detail_fields = ('songs', 'artists', 'desc') @classmethod def get(cls, identifier): @@ -99,7 +83,6 @@ def get(cls, identifier): class XArtistModel(ArtistModel, XBaseModel): - _detail_fields = ('cover', 'desc') @classmethod def get(cls, identifier): @@ -125,7 +108,6 @@ def songs(self, value): class XPlaylistModel(PlaylistModel, XBaseModel): - _detail_fields = ('songs', 'desc') class Meta: fields = ('uid', ) diff --git a/setup.py b/setup.py index 9b8e0e5..7f18d68 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ # 但目前导入 fuocore 包时,会执行 mpv.py 中部分代码, # 执行过程可能会有一些副作用。 # NOTE: 记得更新 docs/source/conf.py 中文档版本 - version='2.2', + version='2.3a0', description='feeluown core', author='Cosven', author_email='yinshaowen241@gmail.com', diff --git a/tests/test_model.py b/tests/test_model.py index 9e4ada7..6bd17ae 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,9 +1,10 @@ +from collections import namedtuple from unittest import TestCase -from fuocore.models import Model +from fuocore.models import Model, BaseModel, display_property -class FakeProvider(object): +class FakeProvider: identifier = 'fake' name = 'fake' @@ -23,7 +24,7 @@ class Meta: provider = provider song = SongModel() - self.assertEqual(song._meta.provider.name, 'fake') + self.assertEqual(song.meta.provider.name, 'fake') def test_meta_class_inherit(self): class SongModel(Model): @@ -34,7 +35,7 @@ class LastSongModel(SongModel): pass song = LastSongModel() - self.assertEqual(song._meta.model_type, 1) + self.assertEqual(song.meta.model_type, 1) def test_meta_class_inherit_with_override(self): class SongModel(Model): @@ -46,5 +47,43 @@ class Meta: provider = provider song = LastSongModel() - self.assertEqual(song._meta.model_type, 1) - self.assertEqual(song._meta.provider.name, 'fake') + self.assertEqual(song.meta.model_type, 1) + self.assertEqual(song.meta.provider.name, 'fake') + + +class TestBaseModel(TestCase): + def test_display_fields(self): + class SongModel(BaseModel): + class Meta: + fields = ['title', 'album'] + fields_display = ['album_name'] + + @property + def album_name(self): + return self.album.name if self.album else '' + + album_name = 'Minutes-to-Midnight' + song = SongModel.create_by_display(identifier=1, album_name=album_name) + self.assertEqual(song.album_name_display, album_name) + self.assertEqual(song.album_name, '') + + real_album_name = 'Minutes to Midnight' + song.title = 'Leave out all the rest' + Album = namedtuple('Album', ('name', )) + song.album = Album(real_album_name) + song.use_display = False + self.assertEqual(song.album_name, real_album_name) + + +class TestDisplayProperty(TestCase): + def test_display_basic_usage(self): + class A: + stage = 4 + a_display = display_property('a') + + a1 = A() + a2 = A() + self.assertEqual(a1.a_display, '') + a2.a_display = 'a2' + self.assertEqual(a1.a_display, '') + self.assertEqual(a2.a_display, 'a2')