2022-12-07 19:09:05 -05:00
|
|
|
from polymorphic.models import PolymorphicModel
|
|
|
|
from django.db import models
|
2022-12-08 05:53:00 +00:00
|
|
|
from catalog.common import jsondata
|
2022-12-07 19:09:05 -05:00
|
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from django.utils import timezone
|
|
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
2022-12-11 23:20:28 +00:00
|
|
|
from django.utils.baseconv import base62
|
|
|
|
from simple_history.models import HistoricalRecords
|
2022-12-07 19:09:05 -05:00
|
|
|
import uuid
|
|
|
|
from .utils import DEFAULT_ITEM_COVER, item_cover_path
|
2022-12-13 18:12:43 +00:00
|
|
|
from .mixins import SoftDeleteMixin
|
2022-12-07 19:09:05 -05:00
|
|
|
# from django.conf import settings
|
|
|
|
|
|
|
|
|
2022-12-15 17:29:35 -05:00
|
|
|
class SiteName(models.TextChoices):
|
|
|
|
Douban = 'douban', _('豆瓣')
|
|
|
|
Goodreads = 'goodreads', _('Goodreads')
|
|
|
|
GoogleBooks = 'googlebooks', _('谷歌图书')
|
|
|
|
IMDB = 'imdb', _('IMDB')
|
|
|
|
TMDB = 'tmdb', _('The Movie Database')
|
|
|
|
Bandcamp = 'bandcamp', _('Bandcamp')
|
2022-12-16 01:08:10 -05:00
|
|
|
Spotify = 'spotify', _('Spotify')
|
2022-12-15 17:29:35 -05:00
|
|
|
IGDB = 'igdb', _('IGDB')
|
|
|
|
Steam = 'steam', _('Steam')
|
|
|
|
Bangumi = 'bangumi', _('Bangumi')
|
|
|
|
ApplePodcast = 'apple_podcast', _('苹果播客')
|
|
|
|
|
|
|
|
|
2022-12-07 19:09:05 -05:00
|
|
|
class IdType(models.TextChoices):
|
|
|
|
WikiData = 'wikidata', _('维基数据')
|
|
|
|
ISBN10 = 'isbn10', _('ISBN10')
|
|
|
|
ISBN = 'isbn', _('ISBN') # ISBN 13
|
|
|
|
ASIN = 'asin', _('ASIN')
|
|
|
|
ISSN = 'issn', _('ISSN')
|
|
|
|
CUBN = 'cubn', _('统一书号')
|
|
|
|
ISRC = 'isrc', _('ISRC') # only for songs
|
2022-12-08 15:45:37 +00:00
|
|
|
GTIN = 'gtin', _('GTIN UPC EAN码') # ISBN is separate
|
2022-12-07 19:09:05 -05:00
|
|
|
Feed = 'feed', _('Feed URL')
|
|
|
|
IMDB = 'imdb', _('IMDb')
|
|
|
|
TMDB_TV = 'tmdb_tv', _('TMDB剧集')
|
|
|
|
TMDB_TVSeason = 'tmdb_tvseason', _('TMDB剧集')
|
|
|
|
TMDB_TVEpisode = 'tmdb_tvepisode', _('TMDB剧集')
|
|
|
|
TMDB_Movie = 'tmdb_movie', _('TMDB电影')
|
|
|
|
Goodreads = 'goodreads', _('Goodreads')
|
|
|
|
Goodreads_Work = 'goodreads_work', _('Goodreads著作')
|
2022-12-09 02:35:21 +00:00
|
|
|
GoogleBooks = 'googlebooks', _('谷歌图书')
|
2022-12-07 19:09:05 -05:00
|
|
|
DoubanBook = 'doubanbook', _('豆瓣读书')
|
|
|
|
DoubanBook_Work = 'doubanbook_work', _('豆瓣读书著作')
|
|
|
|
DoubanMovie = 'doubanmovie', _('豆瓣电影')
|
|
|
|
DoubanMusic = 'doubanmusic', _('豆瓣音乐')
|
|
|
|
DoubanGame = 'doubangame', _('豆瓣游戏')
|
|
|
|
DoubanDrama = 'doubandrama', _('豆瓣舞台剧')
|
|
|
|
Bandcamp = 'bandcamp', _('Bandcamp')
|
|
|
|
Spotify_Album = 'spotify_album', _('Spotify专辑')
|
|
|
|
Spotify_Show = 'spotify_show', _('Spotify播客')
|
2022-12-08 15:45:37 +00:00
|
|
|
Discogs_Release = 'discogs_release', ('Discogs Release')
|
|
|
|
Discogs_Master = 'discogs_master', ('Discogs Master')
|
|
|
|
MusicBrainz = 'musicbrainz', ('MusicBrainz ID')
|
2022-12-07 19:09:05 -05:00
|
|
|
DoubanBook_Author = 'doubanbook_author', _('豆瓣读书作者')
|
|
|
|
DoubanCelebrity = 'doubanmovie_celebrity', _('豆瓣电影影人')
|
|
|
|
Goodreads_Author = 'goodreads_author', _('Goodreads作者')
|
|
|
|
Spotify_Artist = 'spotify_artist', _('Spotify艺术家')
|
|
|
|
TMDB_Person = 'tmdb_person', _('TMDB影人')
|
|
|
|
IGDB = 'igdb', _('IGDB游戏')
|
|
|
|
Steam = 'steam', _('Steam游戏')
|
2022-12-09 03:09:06 +00:00
|
|
|
Bangumi = 'bangumi', _('Bangumi')
|
2022-12-07 19:09:05 -05:00
|
|
|
ApplePodcast = 'apple_podcast', _('苹果播客')
|
|
|
|
|
|
|
|
|
|
|
|
class ItemType(models.TextChoices):
|
|
|
|
Book = 'book', _('书')
|
|
|
|
TV = 'tv', _('剧集')
|
|
|
|
TVSeason = 'tvseason', _('剧集分季')
|
|
|
|
TVEpisode = 'tvepisode', _('剧集分集')
|
|
|
|
Movie = 'movie', _('电影')
|
|
|
|
Music = 'music', _('音乐')
|
|
|
|
Game = 'game', _('游戏')
|
|
|
|
Boardgame = 'boardgame', _('桌游')
|
|
|
|
Podcast = 'podcast', _('播客')
|
|
|
|
FanFic = 'fanfic', _('网文')
|
|
|
|
Performance = 'performance', _('演出')
|
|
|
|
Exhibition = 'exhibition', _('展览')
|
2022-12-12 16:46:37 +00:00
|
|
|
Collection = 'collection', _('收藏单')
|
2022-12-07 19:09:05 -05:00
|
|
|
|
|
|
|
|
2022-12-11 23:20:28 +00:00
|
|
|
class ItemCategory(models.TextChoices):
|
|
|
|
Book = 'book', _('书')
|
|
|
|
Movie = 'movie', _('电影')
|
|
|
|
TV = 'tv', _('剧集')
|
|
|
|
Music = 'music', _('音乐')
|
|
|
|
Game = 'game', _('游戏')
|
|
|
|
Boardgame = 'boardgame', _('桌游')
|
|
|
|
Podcast = 'podcast', _('播客')
|
|
|
|
FanFic = 'fanfic', _('网文')
|
|
|
|
Performance = 'performance', _('演出')
|
|
|
|
Exhibition = 'exhibition', _('展览')
|
2022-12-12 16:46:37 +00:00
|
|
|
Collection = 'collection', _('收藏单')
|
2022-12-11 23:20:28 +00:00
|
|
|
|
|
|
|
|
2022-12-07 19:09:05 -05:00
|
|
|
class SubItemType(models.TextChoices):
|
|
|
|
Season = 'season', _('剧集分季')
|
|
|
|
Episode = 'episode', _('剧集分集')
|
|
|
|
Version = 'version', _('版本')
|
|
|
|
|
|
|
|
# class CreditType(models.TextChoices):
|
|
|
|
# Author = 'author', _('作者')
|
|
|
|
# Translater = 'translater', _('译者')
|
|
|
|
# Producer = 'producer', _('出品人')
|
|
|
|
# Director = 'director', _('电影')
|
|
|
|
# Actor = 'actor', _('演员')
|
|
|
|
# Playwright = 'playwright', _('播客')
|
|
|
|
# VoiceActor = 'voiceactor', _('配音')
|
|
|
|
# Host = 'host', _('主持人')
|
|
|
|
# Developer = 'developer', _('开发者')
|
|
|
|
# Publisher = 'publisher', _('出版方')
|
|
|
|
|
|
|
|
|
|
|
|
class PrimaryLookupIdDescriptor(object): # TODO make it mixin of Field
|
|
|
|
def __init__(self, id_type):
|
|
|
|
self.id_type = id_type
|
|
|
|
|
|
|
|
def __get__(self, instance, cls=None):
|
|
|
|
if instance is None:
|
|
|
|
return self
|
|
|
|
if self.id_type != instance.primary_lookup_id_type:
|
|
|
|
return None
|
|
|
|
return instance.primary_lookup_id_value
|
|
|
|
|
|
|
|
def __set__(self, instance, id_value):
|
|
|
|
if id_value:
|
|
|
|
instance.primary_lookup_id_type = self.id_type
|
|
|
|
instance.primary_lookup_id_value = id_value
|
|
|
|
else:
|
|
|
|
instance.primary_lookup_id_type = None
|
|
|
|
instance.primary_lookup_id_value = None
|
|
|
|
|
|
|
|
|
|
|
|
class LookupIdDescriptor(object): # TODO make it mixin of Field
|
|
|
|
def __init__(self, id_type):
|
|
|
|
self.id_type = id_type
|
|
|
|
|
|
|
|
def __get__(self, instance, cls=None):
|
|
|
|
if instance is None:
|
|
|
|
return self
|
|
|
|
return instance.get_lookup_id(self.id_type)
|
|
|
|
|
|
|
|
def __set__(self, instance, value):
|
|
|
|
instance.set_lookup_id(self.id_type, value)
|
|
|
|
|
|
|
|
|
|
|
|
# class ItemId(models.Model):
|
|
|
|
# item = models.ForeignKey('Item', models.CASCADE)
|
|
|
|
# id_type = models.CharField(_("源网站"), blank=False, choices=IdType.choices, max_length=50)
|
|
|
|
# id_value = models.CharField(_("源网站ID"), blank=False, max_length=1000)
|
|
|
|
|
|
|
|
|
|
|
|
# class ItemCredit(models.Model):
|
|
|
|
# item = models.ForeignKey('Item', models.CASCADE)
|
|
|
|
# credit_type = models.CharField(_("类型"), choices=CreditType.choices, blank=False, max_length=50)
|
|
|
|
# name = models.CharField(_("名字"), blank=False, max_length=1000)
|
|
|
|
|
|
|
|
|
|
|
|
# def check_source_id(sid):
|
|
|
|
# if not sid:
|
|
|
|
# return True
|
|
|
|
# s = sid.split(':')
|
|
|
|
# if len(s) < 2:
|
|
|
|
# return False
|
|
|
|
# return sid[0] in IdType.values()
|
|
|
|
|
|
|
|
|
2022-12-15 17:29:35 -05:00
|
|
|
class Item(SoftDeleteMixin, PolymorphicModel):
|
2022-12-13 06:44:29 +00:00
|
|
|
url_path = None # subclass must specify this
|
2022-12-11 23:20:28 +00:00
|
|
|
category = None # subclass must specify this
|
2022-12-15 17:29:35 -05:00
|
|
|
demonstrative = None # subclass must specify this
|
2022-12-11 23:20:28 +00:00
|
|
|
uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True)
|
2022-12-07 19:09:05 -05:00
|
|
|
title = models.CharField(_("title in primary language"), max_length=1000, default="")
|
|
|
|
brief = models.TextField(_("简介"), blank=True, default="")
|
|
|
|
primary_lookup_id_type = models.CharField(_("isbn/cubn/imdb"), blank=False, null=True, max_length=50)
|
|
|
|
primary_lookup_id_value = models.CharField(_("1234/tt789"), blank=False, null=True, max_length=1000)
|
|
|
|
metadata = models.JSONField(_("其他信息"), blank=True, null=True, default=dict)
|
|
|
|
cover = models.ImageField(upload_to=item_cover_path, default=DEFAULT_ITEM_COVER, blank=True)
|
|
|
|
created_time = models.DateTimeField(auto_now_add=True)
|
|
|
|
edited_time = models.DateTimeField(auto_now=True)
|
2022-12-11 23:20:28 +00:00
|
|
|
is_deleted = models.BooleanField(default=False, db_index=True)
|
|
|
|
history = HistoricalRecords()
|
|
|
|
merged_to_item = models.ForeignKey('Item', null=True, on_delete=models.SET_NULL, default=None, related_name="merged_from_items")
|
2022-12-07 19:09:05 -05:00
|
|
|
|
|
|
|
class Meta:
|
|
|
|
unique_together = [['polymorphic_ctype_id', 'primary_lookup_id_type', 'primary_lookup_id_value']]
|
|
|
|
|
2022-12-13 06:44:29 +00:00
|
|
|
def clear(self):
|
|
|
|
self.primary_lookup_id_value = None
|
|
|
|
self.primary_lookup_id_type = None
|
2022-12-11 23:20:28 +00:00
|
|
|
|
2022-12-07 19:09:05 -05:00
|
|
|
def __str__(self):
|
2022-12-17 23:03:19 -05:00
|
|
|
return f"{self.id}|{self.uuid}{' ' + self.primary_lookup_id_type + ':' + self.primary_lookup_id_value if self.primary_lookup_id_value else ''} ({self.title})"
|
2022-12-07 19:09:05 -05:00
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def get_best_lookup_id(cls, lookup_ids):
|
|
|
|
""" get best available lookup id, ideally commonly used """
|
2022-12-08 18:08:05 +00:00
|
|
|
best_id_types = [
|
2022-12-11 23:20:28 +00:00
|
|
|
IdType.ISBN, IdType.CUBN, IdType.ASIN,
|
2022-12-08 18:08:05 +00:00
|
|
|
IdType.GTIN, IdType.ISRC, IdType.MusicBrainz,
|
2022-12-11 23:20:28 +00:00
|
|
|
IdType.Feed,
|
2022-12-08 18:08:05 +00:00
|
|
|
IdType.IMDB, IdType.TMDB_TVSeason
|
|
|
|
]
|
2022-12-07 19:09:05 -05:00
|
|
|
for t in best_id_types:
|
|
|
|
if lookup_ids.get(t):
|
|
|
|
return t, lookup_ids[t]
|
|
|
|
return list(lookup_ids.items())[0]
|
|
|
|
|
2022-12-11 23:20:28 +00:00
|
|
|
def merge(self, to_item):
|
|
|
|
if to_item is None:
|
|
|
|
raise(ValueError('cannot merge to an empty item'))
|
|
|
|
elif to_item.merged_to_item is not None:
|
|
|
|
raise(ValueError('cannot merge with an item aleady merged'))
|
|
|
|
elif to_item.__class__ != self.__class__:
|
|
|
|
raise(ValueError('cannot merge with an item in different class'))
|
|
|
|
else:
|
|
|
|
self.merged_to_item = to_item
|
|
|
|
|
2022-12-14 21:12:37 -05:00
|
|
|
@property
|
2022-12-17 23:03:19 -05:00
|
|
|
def uuid(self):
|
2022-12-14 21:12:37 -05:00
|
|
|
return base62.encode(self.uid.int)
|
|
|
|
|
2022-12-11 23:20:28 +00:00
|
|
|
@property
|
|
|
|
def url(self):
|
2022-12-17 23:03:19 -05:00
|
|
|
return f'/{self.url_path}/{self.uuid}/'
|
|
|
|
|
|
|
|
@property
|
|
|
|
def api_url(self):
|
|
|
|
return '/api' + self.url
|
2022-12-11 23:20:28 +00:00
|
|
|
|
2022-12-15 17:29:35 -05:00
|
|
|
@property
|
|
|
|
def class_name(self):
|
|
|
|
return self.__class__.__name__.lower()
|
|
|
|
|
2022-12-11 23:20:28 +00:00
|
|
|
@classmethod
|
|
|
|
def get_by_url(cls, url_or_b62):
|
2022-12-16 01:08:10 -05:00
|
|
|
b62 = url_or_b62.strip().split('/')[-2]
|
2022-12-11 23:20:28 +00:00
|
|
|
return cls.objects.get(uid=uuid.UUID(int=base62.decode(b62)))
|
|
|
|
|
2022-12-13 06:44:29 +00:00
|
|
|
# def get_lookup_id(self, id_type: str) -> str:
|
|
|
|
# prefix = id_type.strip().lower() + ':'
|
|
|
|
# return next((x[len(prefix):] for x in self.lookup_ids if x.startswith(prefix)), None)
|
|
|
|
|
2022-12-07 19:09:05 -05:00
|
|
|
def update_lookup_ids(self, lookup_ids):
|
|
|
|
# TODO
|
|
|
|
# ll = set(lookup_ids)
|
|
|
|
# ll = list(filter(lambda a, b: b, ll))
|
|
|
|
pass
|
|
|
|
|
2022-12-08 16:08:59 +00:00
|
|
|
METADATA_COPY_LIST = ['title', 'brief'] # list of metadata keys to copy from resource to item
|
2022-12-07 19:09:05 -05:00
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def copy_metadata(cls, metadata):
|
|
|
|
return dict((k, v) for k, v in metadata.items() if k in cls.METADATA_COPY_LIST and v is not None)
|
|
|
|
|
2022-12-14 21:12:37 -05:00
|
|
|
def has_cover(self):
|
|
|
|
return self.cover and self.cover != DEFAULT_ITEM_COVER
|
|
|
|
|
2022-12-08 16:08:59 +00:00
|
|
|
def merge_data_from_external_resources(self):
|
2022-12-07 19:09:05 -05:00
|
|
|
"""Subclass may override this"""
|
|
|
|
lookup_ids = []
|
2022-12-08 16:08:59 +00:00
|
|
|
for p in self.external_resources.all():
|
2022-12-07 19:09:05 -05:00
|
|
|
lookup_ids.append((p.id_type, p.id_value))
|
|
|
|
lookup_ids += p.other_lookup_ids.items()
|
|
|
|
for k in self.METADATA_COPY_LIST:
|
|
|
|
if not getattr(self, k) and p.metadata.get(k):
|
|
|
|
setattr(self, k, p.metadata.get(k))
|
2022-12-14 21:12:37 -05:00
|
|
|
if not self.has_cover() and p.cover:
|
2022-12-07 19:09:05 -05:00
|
|
|
self.cover = p.cover
|
|
|
|
self.update_lookup_ids(lookup_ids)
|
|
|
|
|
2022-12-08 16:08:59 +00:00
|
|
|
def update_linked_items_from_external_resource(self, resource):
|
2022-12-07 19:09:05 -05:00
|
|
|
"""Subclass should override this"""
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
class ItemLookupId(models.Model):
|
|
|
|
item = models.ForeignKey(Item, null=True, on_delete=models.SET_NULL, related_name='lookup_ids')
|
|
|
|
id_type = models.CharField(_("源网站"), blank=True, choices=IdType.choices, max_length=50)
|
|
|
|
id_value = models.CharField(_("源网站ID"), blank=True, max_length=1000)
|
|
|
|
raw_url = models.CharField(_("源网站ID"), blank=True, max_length=1000, unique=True)
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
unique_together = [['id_type', 'id_value']]
|
|
|
|
|
|
|
|
|
2022-12-08 16:08:59 +00:00
|
|
|
class ExternalResource(models.Model):
|
|
|
|
item = models.ForeignKey(Item, null=True, on_delete=models.SET_NULL, related_name='external_resources')
|
2022-12-07 19:09:05 -05:00
|
|
|
id_type = models.CharField(_("IdType of the source site"), blank=False, choices=IdType.choices, max_length=50)
|
|
|
|
id_value = models.CharField(_("Primary Id on the source site"), blank=False, max_length=1000)
|
2022-12-08 16:08:59 +00:00
|
|
|
url = models.CharField(_("url to the resource"), blank=False, max_length=1000, unique=True)
|
2022-12-07 19:09:05 -05:00
|
|
|
cover = models.ImageField(upload_to=item_cover_path, default=DEFAULT_ITEM_COVER, blank=True)
|
|
|
|
other_lookup_ids = models.JSONField(default=dict)
|
|
|
|
metadata = models.JSONField(default=dict)
|
|
|
|
scraped_time = models.DateTimeField(null=True)
|
|
|
|
created_time = models.DateTimeField(auto_now_add=True)
|
|
|
|
edited_time = models.DateTimeField(auto_now=True)
|
2022-12-08 16:08:59 +00:00
|
|
|
required_resources = jsondata.ArrayField(null=False, blank=False, default=list)
|
|
|
|
related_resources = jsondata.ArrayField(null=False, blank=False, default=list)
|
2022-12-07 19:09:05 -05:00
|
|
|
|
|
|
|
class Meta:
|
|
|
|
unique_together = [['id_type', 'id_value']]
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
return f"{self.id}{':' + self.id_type + ':' + self.id_value if self.id_value else ''} ({self.url})"
|
|
|
|
|
2022-12-14 21:12:37 -05:00
|
|
|
@property
|
|
|
|
def site_name(self):
|
2022-12-15 17:29:35 -05:00
|
|
|
return self.get_site().SITE_NAME
|
2022-12-14 21:12:37 -05:00
|
|
|
|
2022-12-08 16:08:59 +00:00
|
|
|
def update_content(self, resource_content):
|
|
|
|
self.other_lookup_ids = resource_content.lookup_ids
|
|
|
|
self.metadata = resource_content.metadata
|
|
|
|
if resource_content.cover_image and resource_content.cover_image_extention:
|
|
|
|
self.cover = SimpleUploadedFile('temp.' + resource_content.cover_image_extention, resource_content.cover_image)
|
2022-12-17 02:04:12 -05:00
|
|
|
else:
|
|
|
|
self.cover = resource_content.metadata.get('cover_image_path')
|
2022-12-07 19:09:05 -05:00
|
|
|
self.scraped_time = timezone.now()
|
|
|
|
self.save()
|
|
|
|
|
|
|
|
@property
|
|
|
|
def ready(self):
|
2022-12-08 05:53:00 +00:00
|
|
|
return bool(self.metadata and self.scraped_time)
|
2022-12-07 19:09:05 -05:00
|
|
|
|
|
|
|
def get_all_lookup_ids(self):
|
|
|
|
d = self.other_lookup_ids.copy()
|
|
|
|
d[self.id_type] = self.id_value
|
|
|
|
d = {k: v for k, v in d.items() if bool(v)}
|
|
|
|
return d
|
|
|
|
|
|
|
|
def get_preferred_model(self):
|
|
|
|
model = self.metadata.get('preferred_model')
|
|
|
|
if model:
|
|
|
|
m = ContentType.objects.filter(app_label='catalog', model=model.lower()).first()
|
|
|
|
if m:
|
|
|
|
return m.model_class()
|
|
|
|
else:
|
|
|
|
raise ValueError(f'preferred model {model} does not exist')
|
|
|
|
return None
|