fetch IMDB episode; generalize data model for episode comments; add tv episode comment

This commit is contained in:
Your Name 2023-06-16 17:47:22 -04:00 committed by Henri Dickson
parent 6a6348c2e8
commit cd2081a6af
50 changed files with 17320 additions and 302 deletions

View file

@ -28,8 +28,8 @@ class SiteName(models.TextChoices):
Goodreads = "goodreads", _("Goodreads") Goodreads = "goodreads", _("Goodreads")
GoogleBooks = "googlebooks", _("谷歌图书") GoogleBooks = "googlebooks", _("谷歌图书")
BooksTW = "bookstw", _("博客来") BooksTW = "bookstw", _("博客来")
IMDB = "imdb", _("IMDB") IMDB = "imdb", _("IMDb")
TMDB = "tmdb", _("The Movie Database") TMDB = "tmdb", _("TMDB")
Bandcamp = "bandcamp", _("Bandcamp") Bandcamp = "bandcamp", _("Bandcamp")
Spotify = "spotify", _("Spotify") Spotify = "spotify", _("Spotify")
IGDB = "igdb", _("IGDB") IGDB = "igdb", _("IGDB")

View file

@ -212,14 +212,16 @@ class AbstractSite:
self.scrape_additional_data() self.scrape_additional_data()
if auto_link: if auto_link:
for linked_resource in p.required_resources: for linked_resource in p.required_resources:
linked_site = SiteManager.get_site_by_url(linked_resource["url"]) linked_url = linked_resource.get("url")
if linked_site: if linked_url:
linked_site.get_resource_ready( linked_site = SiteManager.get_site_by_url(linked_url)
auto_link=False, if linked_site:
preloaded_content=linked_resource.get("content"), linked_site.get_resource_ready(
) auto_link=False,
else: preloaded_content=linked_resource.get("content"),
_logger.error(f'unable to get site for {linked_resource["url"]}') )
else:
_logger.error(f"unable to get site for {linked_url}")
if p.related_resources: if p.related_resources:
django_rq.get_queue("crawl").enqueue(crawl_related_resources_task, p.pk) django_rq.get_queue("crawl").enqueue(crawl_related_resources_task, p.pk)
if p.item: if p.item:

View file

@ -66,6 +66,10 @@ class Podcast(Item):
return None return None
return f"http://{self.primary_lookup_id_value}" return f"http://{self.primary_lookup_id_value}"
@property
def child_items(self):
return self.episodes.all()
class PodcastEpisode(Item): class PodcastEpisode(Item):
category = ItemCategory.Podcast category = ItemCategory.Podcast

View file

@ -11,6 +11,13 @@ _logger = logging.getLogger(__name__)
@SiteManager.register @SiteManager.register
class IMDB(AbstractSite): class IMDB(AbstractSite):
"""
IMDb site manager
IMDB ids map to Movie, TVShow or TVEpisode
IMDB
"""
SITE_NAME = SiteName.IMDB SITE_NAME = SiteName.IMDB
ID_TYPE = IdType.IMDB ID_TYPE = IdType.IMDB
URL_PATTERNS = [ URL_PATTERNS = [
@ -25,6 +32,8 @@ class IMDB(AbstractSite):
def scrape(self): def scrape(self):
res_data = search_tmdb_by_imdb_id(self.id_value) res_data = search_tmdb_by_imdb_id(self.id_value)
url = None
pd = None
if ( if (
"movie_results" in res_data "movie_results" in res_data
and len(res_data["movie_results"]) > 0 and len(res_data["movie_results"]) > 0
@ -46,21 +55,15 @@ class IMDB(AbstractSite):
tv_id = res_data["tv_episode_results"][0]["show_id"] tv_id = res_data["tv_episode_results"][0]["show_id"]
season_number = res_data["tv_episode_results"][0]["season_number"] season_number = res_data["tv_episode_results"][0]["season_number"]
episode_number = res_data["tv_episode_results"][0]["episode_number"] episode_number = res_data["tv_episode_results"][0]["episode_number"]
if season_number == 0: url = f"https://www.themoviedb.org/tv/{tv_id}/season/{season_number}/episode/{episode_number}"
url = f"https://www.themoviedb.org/tv/{tv_id}/season/{season_number}/episode/{episode_number}" if url:
elif episode_number == 1: tmdb = SiteManager.get_site_by_url(url)
url = f"https://www.themoviedb.org/tv/{tv_id}/season/{season_number}" pd = tmdb.scrape()
else: pd.metadata["preferred_model"] = tmdb.DEFAULT_MODEL.__name__
raise ParseError( pd.metadata["required_resources"] = [] # do not auto fetch parent season
self, if not pd:
"IMDB id matching TMDB but not first episode, this is not supported", # if IMDB id not found in TMDB, use real IMDB scraper
) pd = self.scrape_imdb()
else:
# IMDB id not found in TMDB use real IMDB scraper
return self.scrape_imdb()
tmdb = SiteManager.get_site_by_url(url)
pd = tmdb.scrape()
pd.metadata["preferred_model"] = tmdb.DEFAULT_MODEL.__name__
return pd return pd
def scrape_imdb(self): def scrape_imdb(self):
@ -81,9 +84,17 @@ class IMDB(AbstractSite):
if d.get("primaryImage") if d.get("primaryImage")
else None, else None,
} }
if d.get("series"):
episode_info = d["series"].get("episodeNumber")
if episode_info:
data["season_number"] = episode_info["seasonNumber"]
data["episode_number"] = episode_info["episodeNumber"]
series = d["series"].get("series")
if series:
data["show_imdb_id"] = series["id"]
# TODO more data fields and localized title (in <url>releaseinfo/) # TODO more data fields and localized title (in <url>releaseinfo/)
data["preferred_model"] = ( data["preferred_model"] = (
"" # "TVSeason" not supported yet "TVEpisode"
if data["is_episode"] if data["is_episode"]
else ("TVShow" if data["is_series"] else "Movie") else ("TVShow" if data["is_series"] else "Movie")
) )
@ -100,3 +111,60 @@ class IMDB(AbstractSite):
f'failed to download cover for {self.url} from {pd.metadata["cover_image_url"]}' f'failed to download cover for {self.url} from {pd.metadata["cover_image_url"]}'
) )
return pd return pd
@staticmethod
def get_episode_list(show_id, season_id):
url = f"https://m.imdb.com/title/{show_id}/"
h = BasicDownloader(url).download().html()
show_url = "".join(
h.xpath('//a[@data-testid="hero-title-block__series-link"]/@href')
).split("?")[0]
if not show_url:
show_url = f"/title/{show_id}/"
url = f"https://m.imdb.com{show_url}episodes/?season={season_id}"
h = BasicDownloader(url).download().html()
episodes = []
for e in h.xpath('//div[@id="eplist"]/div/a'):
episode_number = e.xpath(
'./span[contains(@class,"episode-list__title")]/text()'
)[0].strip()
episode_number = int(episode_number.split(".")[0])
episode_title = " ".join(
e.xpath('.//strong[@class="episode-list__title-text"]/text()')
).strip()
episode_url = e.xpath("./@href")[0]
episode_url = "https://www.imdb.com" + episode_url
episodes.append(
{
"model": "TVEpisode",
"id_type": IdType.IMDB,
"id_value": IMDB.url_to_id(episode_url),
"url": episode_url,
"title": episode_title,
"episode_number": episode_number,
}
)
return episodes
@staticmethod
def fetch_episodes_for_season(season_uuid):
season = TVSeason.get_by_url(season_uuid)
if not season.season_number or not season.imdb:
_logger.warning(f"season {season} is missing season number or imdb id")
return
episodes = IMDB.get_episode_list(season.imdb, season.season_number)
if not episodes:
_logger.warning(f"season {season} has no episodes fetched")
return
if not season.episode_count or season.episode_count < len(episodes):
season.episode_count = len(episodes)
season.save()
for e in episodes:
episode = TVEpisode.objects.filter(
season=season, episode_number=e["episode_number"]
).first()
if not episode:
site = SiteManager.get_site_by_url(e["url"])
episode = site.get_resource_ready().item
episode.set_parent_item(season)
episode.save()

View file

@ -417,3 +417,85 @@ class TMDB_TVSeason(AbstractSite):
raise ParseError("first episode id for season") raise ParseError("first episode id for season")
pd.lookup_ids[IdType.IMDB] = d2["external_ids"].get("imdb_id") pd.lookup_ids[IdType.IMDB] = d2["external_ids"].get("imdb_id")
return pd return pd
@SiteManager.register
class TMDB_TVEpisode(AbstractSite):
SITE_NAME = SiteName.TMDB
ID_TYPE = IdType.TMDB_TVEpisode
URL_PATTERNS = [
r"\w+://www.themoviedb.org/tv/(\d+)[^/]*/season/(\d+)/episode/(\d+)[^/]*$"
]
WIKI_PROPERTY_ID = "?"
DEFAULT_MODEL = TVEpisode
ID_PATTERN = r"^(\d+)-(\d+)-(\d+)$"
@classmethod
def url_to_id(cls, url: str):
u = next(
iter([re.match(p, url) for p in cls.URL_PATTERNS if re.match(p, url)]), None
)
return u[1] + "-" + u[2] + "-" + u[3] if u else None
@classmethod
def id_to_url(cls, id_value):
v = id_value.split("-")
return f"https://www.themoviedb.org/tv/{v[0]}/season/{v[1]}/episode/{v[2]}"
def scrape(self):
v = self.id_value.split("-")
show_id = v[0]
season_id = v[1]
episode_id = v[2]
site = TMDB_TV(TMDB_TV.id_to_url(show_id))
show_resource = site.get_resource_ready(auto_create=False, auto_link=False)
api_url = f"https://api.themoviedb.org/3/tv/{show_id}/season/{season_id}/episode/{episode_id}?api_key={settings.TMDB_API3_KEY}&language=zh-CN&append_to_response=external_ids,credits"
d = BasicDownloader(api_url).download().json()
if not d.get("id"):
raise ParseError("id")
pd = ResourceContent(
metadata=_copy_dict(
d,
{
"name": "title",
"overview": "brief",
# "air_date": "air_date",
"season_number": 0,
"episode_number": 0,
"external_ids": [],
},
)
)
pd.metadata["required_resources"] = [
{
"model": "TVSeason",
"id_type": IdType.TMDB_TVSeason,
"id_value": f"{show_id}-{season_id}",
"title": f"TMDB TV Season {show_id}-{season_id}",
"url": f"https://www.themoviedb.org/tv/{show_id}/season/{season_id}",
}
]
pd.lookup_ids[IdType.IMDB] = d["external_ids"].get("imdb_id")
pd.metadata["cover_image_url"] = (
("https://image.tmdb.org/t/p/original/" + d["poster_path"])
if d.get("poster_path")
else None
)
pd.metadata["title"] = (
pd.metadata["title"]
if pd.metadata["title"]
else f'S{d["season_number"]} E{d["episode_number"]}'
)
if pd.metadata["cover_image_url"]:
imgdl = BasicImageDownloader(pd.metadata["cover_image_url"], self.url)
try:
pd.cover_image = imgdl.download().content
pd.cover_image_extention = imgdl.extention
except Exception:
_logger.debug(
f'failed to download cover for {self.url} from {pd.metadata["cover_image_url"]}'
)
if pd.lookup_ids.get(IdType.IMDB):
pd.lookup_ids[IdType.IMDB] = pd.lookup_ids[IdType.IMDB]
return pd

View file

@ -7,6 +7,24 @@
{% load truncate %} {% load truncate %}
{% load duration %} {% load duration %}
{% load user_actions %} {% load user_actions %}
{% if item.class_name == "tvseason" and not request.GET.last %}
<p>
<small>
{% if item.episodes.all %}
<span class="season-number"><a class="current">全部</a></span>
{% for ep in item.episodes.all %}
<span class="season-number">
<a hx-swap="innerHTML"
hx-get="{% url "catalog:comments_by_episode" item.url_path item.uuid %}?episode_uuid={{ ep.uuid }}"
hx-target="#comments">第{{ ep.episode_number }}集</a>
</span>
{% endfor %}
{% else %}
<i class="empty">编辑本条目添加分集信息后可开启分集短评功能。</i>
{% endif %}
</small>
</p>
{% endif %}
{% for comment in comments %} {% for comment in comments %}
{% if forloop.counter <= 10 %} {% if forloop.counter <= 10 %}
<section> <section>

View file

@ -0,0 +1,85 @@
{% load static %}
{% load i18n %}
{% load l10n %}
{% load admin_url %}
{% load mastodon %}
{% load oauth_token %}
{% load truncate %}
{% load duration %}
{% load user_actions %}
<div id="comments_by_episode">
{% if not request.GET.last %}
<p>
<small>
<span class="season-number">
<a hx-swap="innerHTML"
hx-get="{% url "catalog:comments" item.url_path item.uuid %}"
hx-target="#comments">全部</a>
</span>
{% for ep in item.episodes.all %}
<span class="season-number">
<a hx-swap="innerHTML"
{% if ep.uuid == episode_uuid %} class="current" {% else %} hx-get="{% url "catalog:comments_by_episode" item.url_path item.uuid %}?episode_uuid={{ ep.uuid }}" {% endif %}
hx-target="#comments">第{{ ep.episode_number }}集</a>
</span>
{% endfor %}
</small>
</p>
<p>
<small>
<a href="#"
hx-get="{% url 'journal:comment' episode_uuid %}"
class="item-mark-icon"
hx-target="body"
hx-swap="beforeend">
{% if mark.comment_text %}
<i class="fa-solid fa-pen-to-square"></i>
{% else %}
<i class="fa-regular fa-square-plus"></i>
{% endif %}
写该集短评
</a>
</small>
</p>
{% endif %}
{% for comment in comments %}
{% if forloop.counter <= 10 %}
<section>
<span class="action">
<span>
{% liked_piece comment as liked %}
{% include 'like_stats.html' with liked=liked piece=comment %}
</span>
<span>
<a target="_blank"
rel="noopener"
{% if comment.metadata.shared_link %} href="{{ comment.metadata.shared_link }}" title="打开联邦网络分享链接" {% else %} class="disabled" {% endif %}><i class="fa-solid {% if comment.visibility > 0 %} fa-lock {% else %} fa-globe {% endif %}"></i></a>
</span>
</span>
<span>
{% if comment.rating_grade %}{{ comment.rating_grade|rating_star }}{% endif %}
<a href="{% url 'journal:user_profile' comment.owner.mastodon_username %}"
class="nickname"
title="@{{ comment.owner.mastodon_username }}">{{ comment.owner.display_name }}</a>
</span>
<span class="action inline">
<span class="timestamp">
{{ comment.created_time|date }}
{{ comment.mark.action_label }}
</span>
</span>
{% if comment.focus_item %}<a href="{{ comment.focus_item.url }}">{{ comment.focus_item.title }}</a>{% endif %}
{% if comment.item != item %}<a href="{{ comment.item.url }}">{{ comment.item.title }}</a>{% endif %}
<div>{{ comment.html|safe }}</div>
</section>
{% else %}
<a hx-get="{% url "catalog:comments_by_episode" item.url_path item.uuid %}?episode_uuid={{ episode_uuid }}&last={{ comment.created_time|date:'Y-m-d H:i:s.uO'|urlencode }}"
hx-trigger="click"
hx-swap="outerHTML">
<button class="outline">显示更多</button>
</a>
{% endif %}
{% empty %}
<div class="empty">{% trans '暂无' %}</div>
{% endfor %}
</div>

View file

@ -77,13 +77,28 @@
{% endfor %} {% endfor %}
{% if item.child_class %} {% if item.child_class %}
<details> <details>
<summary>{% trans '建子条目' %}</summary> <summary>{% trans '建子条目' %}</summary>
<form method="get" action="{% url 'catalog:create' item.child_class %}"> <form method="get" action="{% url 'catalog:create' item.child_class %}">
<input name="parent" type=hidden value="{{ item.uuid }}"> <input name="parent" type="hidden" value="{{ item.uuid }}">
<input class="contrast" type="submit" value="{{ item.child_class }}"> <input class="contrast" type="submit" value="{{ item.child_class }}">
</form> </form>
</details> </details>
{% endif %} {% endif %}
{% if item.class_name == "tvseason" %}
<details>
<summary>{% trans '更新单集条目' %}</summary>
{% if item.imdb and item.season_number is not None %}
<form method="post"
action="{% url 'catalog:fetch_tvepisodes' item.url_path item.uuid %}">
{% csrf_token %}
<p>因豆瓣/IMDB/TMDB之间对分季处理的差异少量剧集和动画可能无法返回正确结果更新后请手工确认和清理。</p>
<input class="contrast" type="submit" value="{% trans '更新单集条目' %}">
</form>
{% else %}
<i>⛔️ 获取单集条目需要本季序号和IMDB不便填写也可以手工创建子条目。</i>
{% endif %}
</details>
{% endif %}
{% if item.class_name == "movie" %} {% if item.class_name == "movie" %}
<details> <details>
<summary>{% trans '切换分类' %}</summary> <summary>{% trans '切换分类' %}</summary>

View file

@ -365,24 +365,45 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</section> </section>
<section class="solo-hidden"> <section class="solo-hidden">
<h5>
短评
{% if request.user.is_authenticated %}
<small>
| <a href="{% url 'catalog:mark_list' item.url_path item.uuid %}">{% trans '全部标记' %}</a>
| <a href="{% url 'catalog:mark_list' item.url_path item.uuid 'following' %}">关注的人的标记</a>
</small>
{% endif %}
</h5>
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<div> <div id="comment-default">
<div hx-get="{% url 'catalog:comments' item.url_path item.uuid %}" <h5>
hx-trigger="intersect once" 短评
hx-swap="outerHTML"> <small>
<i class="fa-solid fa-compact-disc fa-spin loading"></i> | <a href="{% url 'catalog:mark_list' item.url_path item.uuid %}">{% trans '全部标记' %}</a>
| <a href="{% url 'catalog:mark_list' item.url_path item.uuid 'following' %}">{% trans '关注的人的标记' %}</a>
</small>
</h5>
<div id="comments">
<div hx-get="{% url 'catalog:comments' item.url_path item.uuid %}"
hx-trigger="intersect once"
hx-swap="outerHTML">
<i class="fa-solid fa-compact-disc fa-spin loading"></i>
</div>
</div> </div>
</div> </div>
{% comment %} {% if item.class_name == "tvseason" %}
<div id="comment-by-episode" style="display: none;">
<h5>
<small>
<a _="on click hide #comment-by-episode then show #comment-default">短评</a>
|
</small>
{% trans '分集短评' %}
<small>
| <a href="{% url 'catalog:mark_list' item.url_path item.uuid %}">{% trans '全部标记' %}</a>
| <a href="{% url 'catalog:mark_list' item.url_path item.uuid 'following' %}">{% trans '关注的人的标记' %}</a>
</small>
</h5>
<div>
<div hx-get="{% url 'catalog:comments_by_episode' item.url_path item.uuid %}" hx-trigger="intersect once" hx-swap="outerHTML" id="#comments_by_episode">
<i class="fa-solid fa-compact-disc fa-spin loading"></i>
</div>
</div>
</div>
{% endif %} {% endcomment %}
{% else %} {% else %}
<h5>短评</h5>
<p class="empty">登录后可见</p> <p class="empty">登录后可见</p>
{% endif %} {% endif %}
</section> </section>

View file

@ -62,27 +62,7 @@
{% empty %} {% empty %}
<div>{% trans '暂无标记' %}</div> <div>{% trans '暂无标记' %}</div>
{% endfor %} {% endfor %}
<div class="pagination"> {% include "_pagination.html" %}
{% if marks.pagination.has_prev %}
<a href="?page=1" class="pagination__nav-link pagination__nav-link">&laquo;</a>
<a href="?page={{ marks.previous_page_number }}"
class="pagination__nav-link pagination__nav-link--right-margin pagination__nav-link">&lsaquo;</a>
{% endif %}
{% for page in marks.pagination.page_range %}
{% if page == marks.pagination.current_page %}
<a href="?page={{ page }}"
class="pagination__page-link pagination__page-link--current">{{ page }}</a>
{% else %}
<a href="?page={{ page }}" class="pagination__page-link">{{ page }}</a>
{% endif %}
{% endfor %}
{% if marks.pagination.has_next %}
<a href="?page={{ marks.next_page_number }}"
class="pagination__nav-link pagination__nav-link--left-margin">&rsaquo;</a>
<a href="?page={{ marks.pagination.last_page }}"
class="pagination__nav-link">&raquo;</a>
{% endif %}
</div>
</div> </div>
<aside class="grid__aside top"> <aside class="grid__aside top">
{% include "_sidebar_item.html" %} {% include "_sidebar_item.html" %}

View file

@ -56,27 +56,7 @@
{% empty %} {% empty %}
<div>{% trans '暂无评论' %}</div> <div>{% trans '暂无评论' %}</div>
{% endfor %} {% endfor %}
<div class="pagination"> {% include "_pagination.html" %}
{% if reviews.pagination.has_prev %}
<a href="?page=1" class="pagination__nav-link pagination__nav-link">&laquo;</a>
<a href="?page={{ reviews.previous_page_number }}"
class="pagination__nav-link pagination__nav-link--right-margin pagination__nav-link">&lsaquo;</a>
{% endif %}
{% for page in reviews.pagination.page_range %}
{% if page == reviews.pagination.current_page %}
<a href="?page={{ page }}"
class="pagination__page-link pagination__page-link--current">{{ page }}</a>
{% else %}
<a href="?page={{ page }}" class="pagination__page-link">{{ page }}</a>
{% endif %}
{% endfor %}
{% if reviews.pagination.has_next %}
<a href="?page={{ reviews.next_page_number }}"
class="pagination__nav-link pagination__nav-link--left-margin">&rsaquo;</a>
<a href="?page={{ reviews.pagination.last_page }}"
class="pagination__nav-link">&raquo;</a>
{% endif %}
</div>
</div> </div>
<aside class="grid__aside top"> <aside class="grid__aside top">
{% include "_sidebar_item.html" %} {% include "_sidebar_item.html" %}

View file

@ -20,7 +20,7 @@
<a style="margin-right: 10px" <a style="margin-right: 10px"
title="评论单集" title="评论单集"
href="#" href="#"
hx-get="{% url 'journal:comment' item.uuid ep.uuid %}" hx-get="{% url 'journal:comment' ep.uuid %}"
hx-target="body" hx-target="body"
hx-swap="beforeend"><i class="fa-regular fa-comment-dots"></i></a> hx-swap="beforeend"><i class="fa-regular fa-comment-dots"></i></a>
{% endif %} {% endif %}

View file

@ -231,6 +231,7 @@ class TVSeason(Item):
category = ItemCategory.TV category = ItemCategory.TV
url_path = "tv/season" url_path = "tv/season"
demonstrative = _("这季剧集") demonstrative = _("这季剧集")
child_class = "TVEpisode"
douban_movie = PrimaryLookupIdDescriptor(IdType.DoubanMovie) douban_movie = PrimaryLookupIdDescriptor(IdType.DoubanMovie)
imdb = PrimaryLookupIdDescriptor(IdType.IMDB) imdb = PrimaryLookupIdDescriptor(IdType.IMDB)
tmdb_tvseason = PrimaryLookupIdDescriptor(IdType.TMDB_TVSeason) tmdb_tvseason = PrimaryLookupIdDescriptor(IdType.TMDB_TVSeason)
@ -391,16 +392,38 @@ class TVSeason(Item):
def set_parent_item(self, value): def set_parent_item(self, value):
self.show = value self.show = value
@property
def child_items(self):
return self.episodes.all()
class TVEpisode(Item): class TVEpisode(Item):
category = ItemCategory.TV category = ItemCategory.TV
url_path = "tv/episode" url_path = "tv/episode"
show = models.ForeignKey(
TVShow, null=True, on_delete=models.SET_NULL, related_name="episodes"
)
season = models.ForeignKey( season = models.ForeignKey(
TVSeason, null=True, on_delete=models.SET_NULL, related_name="episodes" TVSeason, null=True, on_delete=models.SET_NULL, related_name="episodes"
) )
season_number = jsondata.IntegerField(null=True)
episode_number = models.PositiveIntegerField(null=True) episode_number = models.PositiveIntegerField(null=True)
imdb = PrimaryLookupIdDescriptor(IdType.IMDB) imdb = PrimaryLookupIdDescriptor(IdType.IMDB)
METADATA_COPY_LIST = ["title", "brief", "episode_number"] METADATA_COPY_LIST = ["title", "brief", "season_number", "episode_number"]
@property
def display_title(self):
return f"{self.season.display_title}{self.episode_number}" # TODO i18n
@property
def parent_item(self):
return self.season
def set_parent_item(self, value):
self.season = value
def update_linked_items_from_external_resource(self, resource):
for w in resource.required_resources:
if w["model"] == "TVSeason":
p = ExternalResource.objects.filter(
id_type=w["id_type"], id_value=w["id_value"]
).first()
if p and p.item:
self.season = p.item

View file

@ -1,6 +1,7 @@
from django.test import TestCase from django.test import TestCase
from catalog.common import * from catalog.common import *
from catalog.tv.models import * from catalog.tv.models import *
from catalog.sites.imdb import IMDB
class JSONFieldTestCase(TestCase): class JSONFieldTestCase(TestCase):
@ -76,6 +77,25 @@ class TMDBTVSeasonTestCase(TestCase):
self.assertEqual(site.resource.item.show.imdb, "tt0436992") self.assertEqual(site.resource.item.show.imdb, "tt0436992")
class TMDBEpisodeTestCase(TestCase):
@use_local_response
def test_scrape_tmdb(self):
t_url = "https://www.themoviedb.org/tv/57243-doctor-who/season/4/episode/1"
site = SiteManager.get_site_by_url(t_url)
self.assertEqual(site.ready, False)
self.assertEqual(site.id_value, "57243-4-1")
site.get_resource_ready()
self.assertEqual(site.ready, True)
self.assertEqual(site.resource.metadata["title"], "活宝搭档")
self.assertEqual(site.resource.item.primary_lookup_id_type, IdType.IMDB)
self.assertEqual(site.resource.item.__class__.__name__, "TVEpisode")
self.assertEqual(site.resource.item.imdb, "tt1159991")
self.assertIsNotNone(site.resource.item.season)
self.assertEqual(site.resource.item.season.imdb, "tt1159991")
# self.assertIsNotNone(site.resource.item.season.show)
# self.assertEqual(site.resource.item.season.show.imdb, "tt0436992")
class DoubanMovieTVTestCase(TestCase): class DoubanMovieTVTestCase(TestCase):
@use_local_response @use_local_response
def test_scrape(self): def test_scrape(self):
@ -115,15 +135,15 @@ class MultiTVSitesTestCase(TestCase):
@use_local_response @use_local_response
def test_tvseasons(self): def test_tvseasons(self):
url1 = "https://www.themoviedb.org/tv/57243-doctor-who/season/4" url1 = "https://www.themoviedb.org/tv/57243-doctor-who/season/4"
url2 = "https://www.imdb.com/title/tt1159991/" url2 = "https://movie.douban.com/subject/3627919/"
url3 = "https://movie.douban.com/subject/3627919/" url3 = "https://www.imdb.com/title/tt1159991/"
p1 = SiteManager.get_site_by_url(url1).get_resource_ready() p1 = SiteManager.get_site_by_url(url1).get_resource_ready()
p2 = SiteManager.get_site_by_url(url2).get_resource_ready() p2 = SiteManager.get_site_by_url(url2).get_resource_ready()
p3 = SiteManager.get_site_by_url(url3).get_resource_ready() p3 = SiteManager.get_site_by_url(url3).get_resource_ready()
self.assertEqual(p1.item.imdb, p2.item.imdb) self.assertEqual(p1.item.imdb, p2.item.imdb)
self.assertEqual(p2.item.imdb, p3.item.imdb) self.assertEqual(p2.item.imdb, p3.item.imdb)
self.assertEqual(p1.item.id, p2.item.id) self.assertEqual(p1.item.id, p2.item.id)
self.assertEqual(p2.item.id, p3.item.id) self.assertNotEqual(p2.item.id, p3.item.id)
@use_local_response @use_local_response
def test_miniseries(self): def test_miniseries(self):
@ -161,3 +181,89 @@ class MovieTVModelRecastTestCase(TestCase):
movie = tv.recast_to(Movie) movie = tv.recast_to(Movie)
self.assertEqual(movie.class_name, "movie") self.assertEqual(movie.class_name, "movie")
self.assertEqual(movie.title, "神秘博士") self.assertEqual(movie.title, "神秘博士")
class IMDBTestCase(TestCase):
@use_local_response
def test_fetch_episodes(self):
t_url = "https://movie.douban.com/subject/1920763/"
season = SiteManager.get_site_by_url(t_url).get_resource_ready().item
self.assertIsNotNone(season)
self.assertIsNone(season.season_number)
IMDB.fetch_episodes_for_season(season.uuid)
# no episodes fetch bc no season number
episodes = list(season.episodes.all().order_by("episode_number"))
self.assertEqual(len(episodes), 0)
# set season number and fetch again
season.season_number = 1
season.save()
IMDB.fetch_episodes_for_season(season.uuid)
episodes = list(season.episodes.all().order_by("episode_number"))
self.assertEqual(len(episodes), 2)
# fetch again, no duplicated episodes
IMDB.fetch_episodes_for_season(season.uuid)
episodes2 = list(season.episodes.all().order_by("episode_number"))
self.assertEqual(episodes, episodes2)
# delete one episode and fetch again
episodes[0].delete()
episodes3 = list(season.episodes.all().order_by("episode_number"))
self.assertEqual(len(episodes3), 1)
IMDB.fetch_episodes_for_season(season.uuid)
episodes4 = list(season.episodes.all().order_by("episode_number"))
self.assertEqual(len(episodes4), 2)
self.assertEqual(episodes[1], episodes4[1])
@use_local_response
def test_get_episode_list(self):
l = IMDB.get_episode_list("tt0436992", 4)
self.assertEqual(len(l), 14)
l = IMDB.get_episode_list("tt1205438", 4)
self.assertEqual(len(l), 14)
@use_local_response
def test_tvshow(self):
t_url = "https://m.imdb.com/title/tt10751754/"
site = SiteManager.get_site_by_url(t_url)
self.assertEqual(site.ready, False)
self.assertEqual(site.id_value, "tt10751754")
site.get_resource_ready()
self.assertEqual(site.ready, True)
self.assertEqual(site.resource.metadata["title"], "Li Shi Na Xie Shi")
self.assertEqual(site.resource.item.primary_lookup_id_type, IdType.IMDB)
self.assertEqual(site.resource.item.__class__.__name__, "TVShow")
self.assertEqual(site.resource.item.year, 2018)
self.assertEqual(site.resource.item.imdb, "tt10751754")
@use_local_response
def test_tvepisode_from_tmdb(self):
t_url = "https://m.imdb.com/title/tt1159991/"
site = SiteManager.get_site_by_url(t_url)
self.assertEqual(site.ready, False)
self.assertEqual(site.id_value, "tt1159991")
site.get_resource_ready()
self.assertEqual(site.ready, True)
self.assertEqual(site.resource.metadata["title"], "活宝搭档")
self.assertEqual(site.resource.item.primary_lookup_id_type, IdType.IMDB)
self.assertEqual(site.resource.item.__class__.__name__, "TVEpisode")
self.assertEqual(site.resource.item.imdb, "tt1159991")
self.assertEqual(site.resource.item.season_number, 4)
self.assertEqual(site.resource.item.episode_number, 1)
self.assertIsNone(site.resource.item.season)
# self.assertEqual(site.resource.item.season.imdb, "tt1159991")
# self.assertIsNotNone(site.resource.item.season.show)
# self.assertEqual(site.resource.item.season.show.imdb, "tt0436992")
@use_local_response
def test_tvepisode_from_imdb(self):
t_url = "https://m.imdb.com/title/tt10751820/"
site = SiteManager.get_site_by_url(t_url)
self.assertEqual(site.ready, False)
self.assertEqual(site.id_value, "tt10751820")
site.get_resource_ready()
self.assertEqual(site.ready, True)
self.assertEqual(site.resource.metadata["title"], "Cong tou kai shi")
self.assertEqual(site.resource.item.primary_lookup_id_type, IdType.IMDB)
self.assertEqual(site.resource.item.__class__.__name__, "TVEpisode")
self.assertEqual(site.resource.item.imdb, "tt10751820")
self.assertEqual(site.resource.item.season_number, 2)
self.assertEqual(site.resource.item.episode_number, 1)

View file

@ -72,6 +72,13 @@ urlpatterns = [
remove_unused_seasons, remove_unused_seasons,
name="remove_unused_seasons", name="remove_unused_seasons",
), ),
re_path(
r"^(?P<item_path>"
+ _get_all_url_paths()
+ ")/(?P<item_uuid>[A-Za-z0-9]{21,22})/fetch_tvepisodes$",
fetch_tvepisodes,
name="fetch_tvepisodes",
),
re_path( re_path(
r"^(?P<item_path>" r"^(?P<item_path>"
+ _get_all_url_paths() + _get_all_url_paths()
@ -89,7 +96,14 @@ urlpatterns = [
re_path( re_path(
r"^(?P<item_path>" r"^(?P<item_path>"
+ _get_all_url_paths() + _get_all_url_paths()
+ ")/(?P<item_uuid>[A-Za-z0-9]{21,22})/comments", + ")/(?P<item_uuid>[A-Za-z0-9]{21,22})/comments_by_episode$",
comments_by_episode,
name="comments_by_episode",
),
re_path(
r"^(?P<item_path>"
+ _get_all_url_paths()
+ ")/(?P<item_uuid>[A-Za-z0-9]{21,22})/comments$",
comments, comments,
name="comments", name="comments",
), ),

View file

@ -8,6 +8,7 @@ from django.db.models import Count
from django.utils import timezone from django.utils import timezone
from django.core.paginator import Paginator from django.core.paginator import Paginator
from catalog.common.models import ExternalResource, IdType, IdealIdTypes from catalog.common.models import ExternalResource, IdType, IdealIdTypes
from catalog.sites.imdb import IMDB
from .models import * from .models import *
from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.clickjacking import xframe_options_exempt
from journal.models import Mark, ShelfMember, Review, Comment, query_item_category from journal.models import Mark, ShelfMember, Review, Comment, query_item_category
@ -323,6 +324,18 @@ def remove_unused_seasons(request, item_path, item_uuid):
return redirect(item.url) return redirect(item.url)
@login_required
def fetch_tvepisodes(request, item_path, item_uuid):
if request.method != "POST":
raise BadRequest()
item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid))
if item.class_name != "tvseason" or not item.imdb or item.season_number is None:
raise BadRequest()
django_rq.get_queue("crawl").enqueue(IMDB.fetch_episodes_for_season, item.uuid)
messages.add_message(request, messages.INFO, _("已开始更新单集信息"))
return redirect(item.url)
@login_required @login_required
def merge(request, item_path, item_uuid): def merge(request, item_path, item_uuid):
if request.method != "POST": if request.method != "POST":
@ -362,9 +375,7 @@ def mark_list(request, item_path, item_uuid, following_only=False):
paginator = Paginator(queryset, NUM_REVIEWS_ON_LIST_PAGE) paginator = Paginator(queryset, NUM_REVIEWS_ON_LIST_PAGE)
page_number = request.GET.get("page", default=1) page_number = request.GET.get("page", default=1)
marks = paginator.get_page(page_number) marks = paginator.get_page(page_number)
marks.pagination = PageLinksGenerator( pagination = PageLinksGenerator(PAGE_LINK_NUMBER, page_number, paginator.num_pages)
PAGE_LINK_NUMBER, page_number, paginator.num_pages
)
return render( return render(
request, request,
"item_mark_list.html", "item_mark_list.html",
@ -372,6 +383,7 @@ def mark_list(request, item_path, item_uuid, following_only=False):
"marks": marks, "marks": marks,
"item": item, "item": item,
"followeing_only": following_only, "followeing_only": following_only,
"pagination": pagination,
}, },
) )
@ -385,15 +397,14 @@ def review_list(request, item_path, item_uuid):
paginator = Paginator(queryset, NUM_REVIEWS_ON_LIST_PAGE) paginator = Paginator(queryset, NUM_REVIEWS_ON_LIST_PAGE)
page_number = request.GET.get("page", default=1) page_number = request.GET.get("page", default=1)
reviews = paginator.get_page(page_number) reviews = paginator.get_page(page_number)
reviews.pagination = PageLinksGenerator( pagination = PageLinksGenerator(PAGE_LINK_NUMBER, page_number, paginator.num_pages)
PAGE_LINK_NUMBER, page_number, paginator.num_pages
)
return render( return render(
request, request,
"item_review_list.html", "item_review_list.html",
{ {
"reviews": reviews, "reviews": reviews,
"item": item, "item": item,
"pagination": pagination,
}, },
) )
@ -419,6 +430,32 @@ def comments(request, item_path, item_uuid):
) )
@login_required
def comments_by_episode(request, item_path, item_uuid):
item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid))
if not item:
raise Http404()
episode_uuid = request.GET.get("episode_uuid")
if episode_uuid:
ids = [TVEpisode.get_by_url(episode_uuid).id]
else:
ids = item.child_item_ids
queryset = Comment.objects.filter(item_id__in=ids).order_by("-created_time")
queryset = queryset.filter(query_visible(request.user))
before_time = request.GET.get("last")
if before_time:
queryset = queryset.filter(created_time__lte=before_time)
return render(
request,
"_item_comments_by_episode.html",
{
"item": item,
"episode_uuid": episode_uuid,
"comments": queryset[:11],
},
)
@login_required @login_required
def reviews(request, item_path, item_uuid): def reviews(request, item_path, item_uuid):
item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid)) item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid))

View file

@ -14,12 +14,15 @@
margin-right: 0.5em; margin-right: 0.5em;
padding: 0 0.25rem; padding: 0 0.25rem;
border: var(--pico-primary) solid 1px; border: var(--pico-primary) solid 1px;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
&.current { &.current {
color: var(--pico-form-element-background-color); color: var(--pico-form-element-background-color);
background: var(--pico-primary); background: var(--pico-primary);
line-height: 1.2em; line-height: 1.2em;
text-decoration: none; cursor: default;
} }
} }
} }

View file

@ -0,0 +1,23 @@
<div class="pagination">
{% if pagination.has_prev %}
<a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page=1"
class="pagination__nav-link pagination__nav-link">&laquo;</a>
<a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page={{ pagination.previous_page }}"
class="pagination__nav-link pagination__nav-link--right-margin pagination__nav-link">&lsaquo;</a>
{% endif %}
{% for page in pagination.page_range %}
{% if page == pagination.current_page %}
<a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page={{ page }}"
class="pagination__page-link pagination__page-link--current">{{ page }}</a>
{% else %}
<a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page={{ page }}"
class="pagination__page-link">{{ page }}</a>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page={{ pagination.next_page }}"
class="pagination__nav-link pagination__nav-link--left-margin">&rsaquo;</a>
<a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page={{ pagination.last_page }}"
class="pagination__nav-link">&raquo;</a>
{% endif %}
</div>

View file

@ -31,3 +31,8 @@ def rating_star(value):
pct = 0 pct = 0
html = f'<span class="rating-star" data-rating="{v}"><div style="width:{pct}%;"></div></span>' html = f'<span class="rating-star" data-rating="{v}"><div style="width:{pct}%;"></div></span>'
return mark_safe(html) return mark_safe(html)
@register.filter
def make_range(number):
return range(1, number + 1)

View file

@ -11,13 +11,13 @@ class PageLinksGenerator:
length -- the number of page links in pagination length -- the number of page links in pagination
""" """
def __init__(self, length, current_page, total_pages): def __init__(self, length: int, current_page: int, total_pages: int):
current_page = int(current_page) current_page = int(current_page)
self.current_page = current_page self.current_page = current_page
self.previous_page = current_page - 1 if current_page > 1 else None self.previous_page = current_page - 1 if current_page > 1 else None
self.next_page = current_page + 1 if current_page < total_pages else None self.next_page = current_page + 1 if current_page < total_pages else None
self.start_page = None self.start_page = 1
self.end_page = None self.end_page = 1
self.page_range = None self.page_range = None
self.has_prev = None self.has_prev = None
self.has_next = None self.has_next = None

View file

@ -321,6 +321,7 @@ class Review(Content):
MIN_RATING_COUNT = 5 MIN_RATING_COUNT = 5
RATING_INCLUDES_CHILD_ITEMS = ["tvshow", "performance"]
class Rating(Content): class Rating(Content):
@ -333,20 +334,48 @@ class Rating(Content):
@staticmethod @staticmethod
def get_rating_for_item(item): def get_rating_for_item(item):
ids = item.child_item_ids + [item.id] stat = Rating.objects.filter(grade__isnull=False)
stat = Rating.objects.filter(item_id__in=ids, grade__isnull=False).aggregate( if item.class_name in RATING_INCLUDES_CHILD_ITEMS:
average=Avg("grade"), count=Count("item") stat = stat.filter(item_id__in=item.child_item_ids + [item.id])
) else:
stat = stat.filter(item=item)
stat = stat.aggregate(average=Avg("grade"), count=Count("item"))
return round(stat["average"], 1) if stat["count"] >= MIN_RATING_COUNT else None return round(stat["average"], 1) if stat["count"] >= MIN_RATING_COUNT else None
@staticmethod @staticmethod
def get_rating_count_for_item(item): def get_rating_count_for_item(item):
ids = item.child_item_ids + [item.id] stat = Rating.objects.filter(grade__isnull=False)
stat = Rating.objects.filter(item_id__in=ids, grade__isnull=False).aggregate( if item.class_name in RATING_INCLUDES_CHILD_ITEMS:
count=Count("item") stat = stat.filter(item_id__in=item.child_item_ids + [item.id])
) else:
stat = stat.filter(item=item)
stat = stat.aggregate(count=Count("item"))
return stat["count"] return stat["count"]
@staticmethod
def get_rating_distribution_for_item(item):
stat = Rating.objects.filter(grade__isnull=False)
if item.class_name in RATING_INCLUDES_CHILD_ITEMS:
stat = stat.filter(item_id__in=item.child_item_ids + [item.id])
else:
stat = stat.filter(item=item)
stat = stat.values("grade").annotate(count=Count("grade")).order_by("grade")
g = [0] * 11
t = 0
for s in stat:
g[s["grade"]] = s["count"]
t += s["count"]
if t < MIN_RATING_COUNT:
return [0] * 5
r = [
100 * (g[1] + g[2]) // t,
100 * (g[3] + g[4]) // t,
100 * (g[5] + g[6]) // t,
100 * (g[7] + g[8]) // t,
100 * (g[9] + g[10]) // t,
]
return r
@staticmethod @staticmethod
def rate_item_by_user(item, user, rating_grade, visibility=0): def rate_item_by_user(item, user, rating_grade, visibility=0):
if rating_grade and (rating_grade < 1 or rating_grade > 10): if rating_grade and (rating_grade < 1 or rating_grade > 10):
@ -371,30 +400,6 @@ class Rating(Content):
rating = Rating.objects.filter(owner=user, item=item).first() rating = Rating.objects.filter(owner=user, item=item).first()
return (rating.grade or None) if rating else None return (rating.grade or None) if rating else None
@staticmethod
def get_rating_distribution_for_item(item):
stat = (
Rating.objects.filter(item=item, grade__isnull=False)
.values("grade")
.annotate(count=Count("grade"))
.order_by("grade")
)
g = [0] * 11
t = 0
for s in stat:
g[s["grade"]] = s["count"]
t += s["count"]
if t < MIN_RATING_COUNT:
return [0] * 5
r = [
100 * (g[1] + g[2]) // t,
100 * (g[3] + g[4]) // t,
100 * (g[5] + g[6]) // t,
100 * (g[7] + g[8]) // t,
100 * (g[9] + g[10]) // t,
]
return r
Item.rating = property(Rating.get_rating_for_item) Item.rating = property(Rating.get_rating_for_item)
Item.rating_count = property(Rating.get_rating_count_for_item) Item.rating_count = property(Rating.get_rating_count_for_item)
@ -601,8 +606,9 @@ ShelfTypeNames = [
[ItemCategory.Podcast, ShelfType.WISHLIST, _("想听")], [ItemCategory.Podcast, ShelfType.WISHLIST, _("想听")],
[ItemCategory.Podcast, ShelfType.PROGRESS, _("在听")], [ItemCategory.Podcast, ShelfType.PROGRESS, _("在听")],
[ItemCategory.Podcast, ShelfType.COMPLETE, _("听过")], [ItemCategory.Podcast, ShelfType.COMPLETE, _("听过")],
# disable all shelves for PodcastEpisode
[ItemCategory.Performance, ShelfType.WISHLIST, _("想看")], [ItemCategory.Performance, ShelfType.WISHLIST, _("想看")],
# disable progress shelf for performance # disable progress shelf for Performance
[ItemCategory.Performance, ShelfType.PROGRESS, _("")], [ItemCategory.Performance, ShelfType.PROGRESS, _("")],
[ItemCategory.Performance, ShelfType.COMPLETE, _("看过")], [ItemCategory.Performance, ShelfType.COMPLETE, _("看过")],
] ]

View file

@ -13,15 +13,14 @@
<div class="modal-underlay" _="on click trigger closeModal"></div> <div class="modal-underlay" _="on click trigger closeModal"></div>
<div class="modal-content" style="font-size: 80%;"> <div class="modal-content" style="font-size: 80%;">
<div class="add-to-list-modal__head"> <div class="add-to-list-modal__head">
<span class="add-to-list-modal__title"><small>{% trans '评论单集' %} {{ item.title }}: {{ focus_item.title }}</small></span> <span class="add-to-list-modal__title"><small>{% trans '评论' %} {{ item.parent_item.title }}: {{ item.title }}</small></span>
<span class="add-to-list-modal__close-button modal-close" <span class="add-to-list-modal__close-button modal-close"
_="on click trigger closeModal"> _="on click trigger closeModal">
<i class="fa-solid fa-xmark"></i> <i class="fa-solid fa-xmark"></i>
</span> </span>
</div> </div>
<div class="add-to-list-modal__body"> <div class="add-to-list-modal__body">
<form action="{% url 'journal:comment' item.uuid focus_item.uuid %}" <form action="{% url 'journal:comment' item.uuid %}" method="post">
method="post">
{% csrf_token %} {% csrf_token %}
<textarea name="text" <textarea name="text"
cols="40" cols="40"
@ -85,7 +84,7 @@
<div class="mark-modal__confirm-button"> <div class="mark-modal__confirm-button">
<input type="submit" class="button float-right" value="保存"> <input type="submit" class="button float-right" value="保存">
</div> </div>
<div class="mark-modal__option"> <div {% if item.class_name != "podcastepisode" %}style="display:none;"{% endif %}>
<div class="mark-modal__visibility-radio" role="group"> <div class="mark-modal__visibility-radio" role="group">
<div> <div>
<input type="checkbox" name="share_position" value="1" id="share_position"> <input type="checkbox" name="share_position" value="1" id="share_position">

View file

@ -22,43 +22,45 @@
<div class="add-to-list-modal__body"> <div class="add-to-list-modal__body">
<form method="post" action="{% url 'journal:mark' item.uuid %}"> <form method="post" action="{% url 'journal:mark' item.uuid %}">
{% csrf_token %} {% csrf_token %}
<div class="grid mark-line"> {% if shelf_types %}
<div> <div class="grid mark-line">
<fieldset> <div>
{% for k, v in shelf_types %} <fieldset>
{% if v %} {% for k, v in shelf_types %}
<input type="radio" {% if v %}
name="status" <input type="radio"
value="{{ k }}" name="status"
required value="{{ k }}"
id="id_status_{{ k }}" required
{% if k == "wishlist" %} _="on click add .hidden to .rating-editor" {% else %} _="on click remove .hidden from .rating-editor" {% endif %} id="id_status_{{ k }}"
{% if shelf_type == k %}checked=""{% endif %}> {% if k == "wishlist" %} _="on click add .hidden to .rating-editor" {% else %} _="on click remove .hidden from .rating-editor" {% endif %}
<label for="id_status_{{ k }}">{{ v }}</label> {% if shelf_type == k %}checked=""{% endif %}>
{% endif %} <label for="id_status_{{ k }}">{{ v }}</label>
{% endfor %} {% endif %}
</fieldset> {% endfor %}
</fieldset>
</div>
<div>
<span class="rating-editor {% if shelf_type == 'wishlist' %}hidden{% endif %}" _="on mousemove(currentTarget, offsetX) set current_value to Math.round((10 * offsetX) / currentTarget.offsetWidth) set star_div to the
<div/>
in me set star_div.style.width to (current_value * 10) + '%' set @data-tooltip to current_value or '未评分' add .yellow to star_div end on click(currentTarget, offsetX) set current_value to Math.round((10 * offsetX) / currentTarget.offsetWidth) set star_div to the
<div/>
in me set star_input to the
<input/>
in me set star_div.style.width to (current_value * 10) + '%' set @data-tooltip to current_value or '未评分' set star_input.value to current_value end on mouseleave(currentTarget) set star_div to the
<div/>
in me set star_input to the
<input/>
in me set current_value to star_input.value set star_div.style.width to (current_value * 10) + '%' set @data-tooltip to current_value or '未评分' end">
{{ mark.rating_grade|rating_star }}
<input type="hidden"
name="rating_grade"
id="id_rating"
value="{{ mark.rating_grade | default:0 }}">
</span>
</div>
</div> </div>
<div> {% endif %}
<span class="rating-editor {% if shelf_type == 'wishlist' %}hidden{% endif %}" _="on mousemove(currentTarget, offsetX) set current_value to Math.round((10 * offsetX) / currentTarget.offsetWidth) set star_div to the
<div/>
in me set star_div.style.width to (current_value * 10) + '%' set @data-tooltip to current_value or '未评分' add .yellow to star_div end on click(currentTarget, offsetX) set current_value to Math.round((10 * offsetX) / currentTarget.offsetWidth) set star_div to the
<div/>
in me set star_input to the
<input/>
in me set star_div.style.width to (current_value * 10) + '%' set @data-tooltip to current_value or '未评分' set star_input.value to current_value end on mouseleave(currentTarget) set star_div to the
<div/>
in me set star_input to the
<input/>
in me set current_value to star_input.value set star_div.style.width to (current_value * 10) + '%' set @data-tooltip to current_value or '未评分' end">
{{ mark.rating_grade|rating_star }}
<input type="hidden"
name="rating_grade"
id="id_rating"
value="{{ mark.rating_grade | default:0 }}">
</span>
</div>
</div>
<div> <div>
<fieldset> <fieldset>
<textarea name="text" <textarea name="text"

View file

@ -28,29 +28,7 @@
<div>{% trans '无结果' %}</div> <div>{% trans '无结果' %}</div>
{% endfor %} {% endfor %}
</div> </div>
<div class="pagination"> {% include "_pagination.html" %}
{% if members.pagination.has_prev %}
<a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page=1"
class="pagination__nav-link pagination__nav-link">&laquo;</a>
<a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page={{ members.previous_page_number }}"
class="pagination__nav-link pagination__nav-link--right-margin pagination__nav-link">&lsaquo;</a>
{% endif %}
{% for page in members.pagination.page_range %}
{% if page == members.pagination.current_page %}
<a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page={{ page }}"
class="pagination__page-link pagination__page-link--current">{{ page }}</a>
{% else %}
<a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page={{ page }}"
class="pagination__page-link">{{ page }}</a>
{% endif %}
{% endfor %}
{% if members.pagination.has_next %}
<a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page={{ members.next_page_number }}"
class="pagination__nav-link pagination__nav-link--left-margin">&rsaquo;</a>
<a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page={{ members.pagination.last_page }}"
class="pagination__nav-link">&raquo;</a>
{% endif %}
</div>
</div> </div>
{% include "_sidebar.html" with show_profile=1 %} {% include "_sidebar.html" with show_profile=1 %}
</main> </main>

View file

@ -21,7 +21,7 @@ urlpatterns = [
path("like/<str:piece_uuid>", like, name="like"), path("like/<str:piece_uuid>", like, name="like"),
path("unlike/<str:piece_uuid>", unlike, name="unlike"), path("unlike/<str:piece_uuid>", unlike, name="unlike"),
path("mark/<str:item_uuid>", mark, name="mark"), path("mark/<str:item_uuid>", mark, name="mark"),
path("comment/<str:item_uuid>/<str:focus_item_uuid>", comment, name="comment"), path("comment/<str:item_uuid>", comment, name="comment"),
path( path(
"add_to_collection/<str:item_uuid>", add_to_collection, name="add_to_collection" "add_to_collection/<str:item_uuid>", add_to_collection, name="add_to_collection"
), ),

View file

@ -195,22 +195,71 @@ def mark(request, item_uuid):
raise BadRequest() raise BadRequest()
def post_comment(user, item, text, visibility, shared_link=None, position=None):
post_error = False
status_id = get_status_id_by_url(shared_link)
link = (
item.get_absolute_url_with_position(position) if position else item.absolute_url
)
action_label = "评论" if text else "分享"
status = f"{action_label}{ItemCategory(item.category).label}{item.display_title}\n{link}\n\n{text}"
spoiler, status = get_spoiler_text(status, item)
try:
response = post_toot(
user.mastodon_site,
status,
get_visibility(visibility, user),
user.mastodon_token,
False,
status_id,
spoiler,
)
if response and response.status_code in [200, 201]:
j = response.json()
if "url" in j:
shared_link = j["url"]
except Exception as e:
if settings.DEBUG:
raise
post_error = True
return post_error, shared_link
@login_required @login_required
def comment(request, item_uuid, focus_item_uuid): def comment_select_episode(request, item_uuid):
item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid)) item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid))
focus_item = get_object_or_404(Item, uid=get_uuid_or_404(focus_item_uuid))
if focus_item.parent_item != item:
raise Http404()
comment = Comment.objects.filter(
owner=request.user, item=item, focus_item=focus_item
).first()
if request.method == "GET": if request.method == "GET":
return render( return render(
request, request,
"comment.html", "comment_select_episode.html",
{
"item": item,
"comment": comment,
},
)
raise BadRequest()
@login_required
def comment(request, item_uuid):
item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid))
if not item.class_name in ["podcastepisode", "tvepisode"]:
raise BadRequest("不支持评论此类型的条目")
# episode = None
# if item.class_name == "tvseason":
# try:
# episode = int(request.POST.get("episode", 0))
# except:
# episode = 0
# if episode <= 0:
# raise BadRequest("请输入正确的集数")
comment = Comment.objects.filter(owner=request.user, item=item).first()
if request.method == "GET":
return render(
request,
f"comment.html",
{ {
"item": item, "item": item,
"focus_item": focus_item,
"comment": comment, "comment": comment,
}, },
) )
@ -222,61 +271,57 @@ def comment(request, item_uuid, focus_item_uuid):
return HttpResponseRedirect(request.META.get("HTTP_REFERER")) return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
visibility = int(request.POST.get("visibility", default=0)) visibility = int(request.POST.get("visibility", default=0))
text = request.POST.get("text") text = request.POST.get("text")
position = ( position = None
request.POST.get("position") if item.class_name == "podcastepisode":
if request.POST.get("share_position") position = request.POST.get("position") or "0:0:0"
else "0:0:0"
)
try:
pos = datetime.strptime(position, "%H:%M:%S")
position = pos.hour * 3600 + pos.minute * 60 + pos.second
except:
if settings.DEBUG:
raise
position = None
share_to_mastodon = bool(request.POST.get("share_to_mastodon", default=False))
shared_link = None
post_error = False
if share_to_mastodon:
shared_link = comment.metadata.get("shared_link") if comment else None
status_id = get_status_id_by_url(shared_link)
link = focus_item.get_absolute_url_with_position(position or None)
status = f"分享{ItemCategory(item.category).label}{focus_item.display_title}\n{link}\n\n{text}"
spoiler, status = get_spoiler_text(status, item)
try: try:
response = post_toot( pos = datetime.strptime(position, "%H:%M:%S")
request.user.mastodon_site, position = pos.hour * 3600 + pos.minute * 60 + pos.second
status, except:
get_visibility(visibility, request.user),
request.user.mastodon_token,
False,
status_id,
spoiler,
)
if response and response.status_code in [200, 201]:
j = response.json()
if "url" in j:
shared_link = j["url"]
except Exception as e:
if settings.DEBUG: if settings.DEBUG:
raise raise
post_error = True position = None
if comment: share_to_mastodon = bool(request.POST.get("share_to_mastodon", default=False))
comment.visibility = visibility shared_link = comment.metadata.get("shared_link") if comment else None
comment.text = text post_error = False
comment.metadata["position"] = position if share_to_mastodon:
if shared_link: post_error, shared_link = post_comment(
comment.metadata["shared_link"] = shared_link request.user, item, text, visibility, shared_link, position
comment.save()
else:
comment = Comment.objects.create(
owner=request.user,
item=item,
focus_item=focus_item,
text=text,
visibility=visibility,
metadata={"shared_link": shared_link, "position": position},
) )
Comment.objects.update_or_create(
owner=request.user,
item=item,
# metadata__episode=episode,
defaults={
"text": text,
"visibility": visibility,
"metadata": {
"shared_link": shared_link,
"position": position,
},
},
)
# if comment:
# comment.visibility = visibility
# comment.text = text
# comment.metadata["position"] = position
# comment.metadata["episode"] = episode
# if shared_link:
# comment.metadata["shared_link"] = shared_link
# comment.save()
# else:
# comment = Comment.objects.create(
# owner=request.user,
# item=item,
# text=text,
# visibility=visibility,
# metadata={
# "shared_link": shared_link,
# "position": position,
# "episode": episode,
# },
# )
if post_error: if post_error:
return render_relogin(request) return render_relogin(request)
return HttpResponseRedirect(request.META.get("HTTP_REFERER")) return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
@ -648,11 +693,11 @@ def _render_list(
paginator = Paginator(queryset, PAGE_SIZE) paginator = Paginator(queryset, PAGE_SIZE)
page_number = request.GET.get("page", default=1) page_number = request.GET.get("page", default=1)
members = paginator.get_page(page_number) members = paginator.get_page(page_number)
members.pagination = PageLinksGenerator(PAGE_SIZE, page_number, paginator.num_pages) pagination = PageLinksGenerator(PAGE_SIZE, page_number, paginator.num_pages)
return render( return render(
request, request,
f"user_{type}_list.html", f"user_{type}_list.html",
{"user": user, "members": members, "tag": tag}, {"user": user, "members": members, "tag": tag, "pagination": pagination},
) )

View file

@ -21,14 +21,13 @@ _logger = logging.getLogger(__name__)
class ActivityTemplate(models.TextChoices): class ActivityTemplate(models.TextChoices):
""" """
MarkItem = "mark_item" MarkItem = "mark_item"
ReviewItem = "review_item" ReviewItem = "review_item"
CreateCollection = "create_collection" CreateCollection = "create_collection"
LikeCollection = "like_collection" LikeCollection = "like_collection"
FeatureCollection = "feature_collection" FeatureCollection = "feature_collection"
CommentFocusItem = "comment_focus_item" CommentChildItem = "comment_child_item"
CommentFocusItem = "comment_focus_item" # TODO: remove this after migration
class LocalActivity(models.Model, UserOwnedObjectMixin): class LocalActivity(models.Model, UserOwnedObjectMixin):
@ -62,7 +61,7 @@ class ActivityManager:
return ( return (
LocalActivity.objects.filter(q) LocalActivity.objects.filter(q)
.order_by("-created_time") .order_by("-created_time")
.prefetch_related("action_object") .prefetch_related("action_object", "owner")
) # .select_related() https://github.com/django-polymorphic/django-polymorphic/pull/531 ) # .select_related() https://github.com/django-polymorphic/django-polymorphic/pull/531
@staticmethod @staticmethod
@ -70,8 +69,8 @@ class ActivityManager:
return ActivityManager(user) return ActivityManager(user)
User.activity_manager = cached_property(ActivityManager.get_manager_for_user) User.activity_manager = cached_property(ActivityManager.get_manager_for_user) # type: ignore
User.activity_manager.__set_name__(User, "activity_manager") User.activity_manager.__set_name__(User, "activity_manager") # type: ignore
class DataSignalManager: class DataSignalManager:
@ -184,17 +183,31 @@ class FeaturedCollectionProcessor(DefaultActivityProcessor):
template = ActivityTemplate.FeatureCollection template = ActivityTemplate.FeatureCollection
# @DataSignalManager.register
# class CommentFocusItemProcessor(DefaultActivityProcessor):
# model = Comment
# template = ActivityTemplate.CommentFocusItem
# def created(self):
# if self.action_object.focus_item:
# super().created()
# def updated(self):
# if self.action_object.focus_item:
# super().updated()
@DataSignalManager.register @DataSignalManager.register
class CommentFocusItemProcessor(DefaultActivityProcessor): class CommentChildItemProcessor(DefaultActivityProcessor):
model = Comment model = Comment
template = ActivityTemplate.CommentFocusItem template = ActivityTemplate.CommentChildItem
def created(self): def created(self):
if self.action_object.focus_item: if self.action_object.item.class_name in ["podcastepisode", "tvepisode"]:
super().created() super().created()
def updated(self): def updated(self):
if self.action_object.focus_item: if self.action_object.item.class_name in ["podcastepisode", "tvepisode"]:
super().updated() super().updated()

View file

@ -0,0 +1,76 @@
{% load static %}
{% load i18n %}
{% load l10n %}
{% load admin_url %}
{% load mastodon %}
{% load oauth_token %}
{% load truncate %}
{% load thumb %}
{% load prettydate %}
{% load user_actions %}
{% load duration %}
{% wish_item_action activity.action_object.item.parent_item as action %}
{% like_piece_action activity.action_object as like_action %}
<span class="action">
<span>
{% liked_piece activity.action_object as liked %}
{% include 'like_stats.html' with liked=liked piece=activity.action_object %}
</span>
<span>
<a title="评论节目"
hx-get="{% url 'journal:comment' activity.action_object.item.uuid %}"
hx-target="body"
hx-swap="beforeend"><i class="fa-regular fa-comment"></i></a>
</span>
{% if activity.action_object.item.class_name == 'podcastepisode' %}
<span>
<a title="播放节目"
class="episode"
data-uuid="{{ activity.action_object.item.uuid }}"
data-media="{{ activity.action_object.item.media_url }}"
data-cover="{{ activity.action_object.item.cover_url|default:activity.action_object.item.parent_item.cover.url }}"
data-title="{{ activity.action_object.item.title }}"
data-album="{{ activity.action_object.item.parent_item.title }}"
data-hosts="{{ activity.action_object.item.parent_item.hosts|join:' / ' }}"
data-position="{{ activity.action_object.metadata.position | default:0 }}"><i class="fa-solid fa-circle-play"></i></a>
</span>
{% endif %}
<span>
{% if not action.taken %}
<a title="添加标记"
hx-get="{% url 'journal:mark' activity.action_object.item.parent_item.uuid %}?shelf_type=wishlist"
hx-target="body"
hx-swap="beforeend">
<i class="fa-regular fa-bookmark"></i>
</a>
{% else %}
<a title="修改标记"
hx-get="{% url 'journal:mark' activity.action_object.item.parent_item.uuid %}"
hx-target="body"
hx-swap="beforeend">
<i class="fa-solid fa-bookmark"></i>
</a>
{% endif %}
</span>
<span>
<a {% if activity.action_object.metadata.shared_link %} href="{{ activity.action_object.metadata.shared_link }}" target="_blank" rel="noopener" title="打开联邦网络分享链接" {% else %} class="disabled" {% endif %}><i class="fa-solid {% if activity.action_object.visibility > 0 %} fa-lock {% else %} fa-globe {% endif %} "></i></a>
</span>
</span>
<div class="spacing">
{{ activity.action_object.mark.action_label }}
<a href="{{ activity.action_object.item_url }}">{{ activity.action_object.item.title }}</a>
{% if activity.action_object.metadata.position %}
<span class="muted">{{ activity.action_object.metadata.position|duration_format:1 }}</span>
{% endif %}
{% if activity.action_object.mark.rating_grade %}
{{ activity.action_object.mark.rating_grade | rating_star }}
{% endif %}
</div>
<article>
{% include "_item_card.html" with item=activity.action_object.item.parent_item allow_embed=1 %}
{% if activity.action_object.mark.comment_text %}
<footer>
<p>{{ activity.action_object.mark.comment_html|safe }}</p>
</footer>
{% endif %}
</article>

View file

@ -11,14 +11,6 @@
{% load duration %} {% load duration %}
{% wish_item_action activity.action_object.item as action %} {% wish_item_action activity.action_object.item as action %}
<span class="action"> <span class="action">
<span>
<a {% if activity.action_object.metadata.shared_link %} href="{{ activity.action_object.metadata.shared_link }}" target="_blank" rel="noopener" title="打开联邦网络分享链接" {% else %} class="disabled" {% endif %}><i class="fa-solid {% if activity.action_object.visibility > 0 %} fa-lock {% else %} fa-globe {% endif %} "></i></a>
</span>
{% comment %}
<span>
<a><i class="fa-regular fa-comment"></i></a>
</span>
{% endcomment %}
{% if activity.action_object.mark.comment_text %} {% if activity.action_object.mark.comment_text %}
<span> <span>
{% liked_piece activity.action_object.mark.comment as liked %} {% liked_piece activity.action_object.mark.comment as liked %}
@ -47,6 +39,9 @@
</a> </a>
{% endif %} {% endif %}
</span> </span>
<span>
<a {% if activity.action_object.metadata.shared_link %} href="{{ activity.action_object.metadata.shared_link }}" target="_blank" rel="noopener" title="打开联邦网络分享链接" {% else %} class="disabled" {% endif %}><i class="fa-solid {% if activity.action_object.visibility > 0 %} fa-lock {% else %} fa-globe {% endif %} "></i></a>
</span>
</span> </span>
<div class="spacing"> <div class="spacing">
{{ activity.action_object.mark.action_label }} {{ activity.action_object.mark.action_label }}

View file

@ -11,28 +11,10 @@
{% load duration %} {% load duration %}
{% wish_item_action activity.action_object.item as action %} {% wish_item_action activity.action_object.item as action %}
<span class="action"> <span class="action">
{% if activity.action_object.metadata.shared_link %}
<span>
<a href="{{ activity.action_object.metadata.shared_link }}"
target="_blank"
rel="noopener"
title="打开联邦网络分享链接"><i class="fa-solid fa-circle-nodes"></i></a>
</span>
{% endif %}
{% comment %}
<span>
<a><i class="fa-regular fa-comment"></i></a>
</span>
{% endcomment %}
<span> <span>
{% liked_piece activity.action_object as liked %} {% liked_piece activity.action_object as liked %}
{% include 'like_stats.html' with liked=liked piece=activity.action_object %} {% include 'like_stats.html' with liked=liked piece=activity.action_object %}
</span> </span>
{% comment %}
<span>
<a><i class="fa-solid fa-circle-play"></i></a>
</span>
{% endcomment %}
<span> <span>
{% if not action.taken %} {% if not action.taken %}
<a title="添加标记" <a title="添加标记"
@ -50,6 +32,9 @@
</a> </a>
{% endif %} {% endif %}
</span> </span>
<span>
<a {% if activity.action_object.metadata.shared_link %} href="{{ activity.action_object.metadata.shared_link }}" target="_blank" rel="noopener" title="打开联邦网络分享链接" {% else %} class="disabled" {% endif %}><i class="fa-solid {% if activity.action_object.visibility > 0 %} fa-lock {% else %} fa-globe {% endif %} "></i></a>
</span>
</span> </span>
<div class="spacing"> <div class="spacing">
写了评论 写了评论

View file

@ -0,0 +1 @@
{"movie_results":[],"person_results":[],"tv_results":[{"adult":false,"backdrop_path":"/y6EakvqM6pdBX900TzQPkn4v5yF.jpg","id":71365,"name":"太空堡垒卡拉狄加 迷你剧","original_language":"en","original_name":"Battlestar Galactica","overview":"未来世界人类已由地球移民到其它星系居住人们为了贪求私欲便开始研究及制造一种拥有独立思想及感觉的机械人——Cylons来为人类服务。但人们怎样也想不到这些本应是帮助人类的机械人竟会调 转头对抗自已因此人类与自已制造的机械人之战争便开始展开……在经过数十年漫长、死伤无数的战争结朿后人们本以为可得到和平的生活但Cylons明白到即使在休战后始终没辨法与人类建立实际的外交关系故Cylons开始密谋另一个对付人类的计划这次它们将会制造一些与人类一模一样的机械人作间碟渗入人类世界从而以里应外合的方法把人类消灭。","poster_path":"/imTQ4nBdA68TVpLaWhhQJnb7NQh.jpg","media_type":"tv","genre_ids":[10759,18,10765],"popularity":18.149,"first_air_date":"2003-12-08","vote_average":8.19,"vote_count":686,"origin_country":[]}],"tv_episode_results":[],"tv_season_results":[]}

View file

@ -0,0 +1 @@
{"movie_results":[],"person_results":[],"tv_results":[],"tv_episode_results":[],"tv_season_results":[]}

View file

@ -0,0 +1 @@
{"movie_results":[],"person_results":[],"tv_results":[],"tv_episode_results":[],"tv_season_results":[]}

View file

@ -1 +1 @@
{"movie_results":[],"person_results":[],"tv_results":[],"tv_episode_results":[{"id":941505,"name":"活宝搭档","overview":"博士在伦敦发现艾迪派斯公司新产品药物有问题人类服用后会悄悄的产生土豆状生物并在夜里1点10分逃走回到保姆身边于是博士潜入公司决定探查究竟在探查时遇到了多娜原来Adiposian人丢失了他们的繁育星球于是跑到地球利用人类做代孕母繁殖宝宝。最后保姆在高空中被抛弃脂肪球回到了父母身边博士邀请多娜一同旅行。【Rose从平行宇宙回归】","media_type":"tv_episode","vote_average":7.2,"vote_count":43,"air_date":"2008-04-05","episode_number":1,"production_code":"","runtime":null,"season_number":4,"show_id":57243,"still_path":"/cq1zrCS267vGXa3rCYQkVKNJE9v.jpg"}],"tv_season_results":[]} {"movie_results":[],"person_results":[],"tv_results":[],"tv_episode_results":[{"id":941505,"name":"活宝搭档","overview":"博士在伦敦发现艾迪派斯公司新产品药物有问题人类服用后会悄悄的产生土豆状生物并在夜里1点10分逃走回到保姆身边于是博士潜入公司决定探查究竟在探查时遇到了多娜原来Adiposian人丢失了他们的繁育星球于是跑到地球利用人类做代孕母繁殖宝宝。最后保姆在高空中被抛弃脂肪球回到了父母身边博士邀请多娜一同旅行。【Rose从平行宇宙回归】","media_type":"tv_episode","vote_average":7.074,"vote_count":46,"air_date":"2008-04-05","episode_number":1,"production_code":"","runtime":null,"season_number":4,"show_id":57243,"still_path":"/cq1zrCS267vGXa3rCYQkVKNJE9v.jpg"}],"tv_season_results":[]}

View file

@ -0,0 +1 @@
{"movie_results":[],"person_results":[],"tv_results":[],"tv_episode_results":[{"id":1305550,"name":"迷你剧上","overview":"40年的和平结束人类遭受到赛昂人灭绝种族式的攻击幸存者被迫逃离他们的12个殖民地。","media_type":"tv_episode","vote_average":8.1,"vote_count":20,"air_date":"2003-12-08","episode_number":1,"production_code":"","runtime":95,"season_number":1,"show_id":71365,"still_path":"/mBkKJW9ppIEjkD4CXGaAntekQNm.jpg"}],"tv_season_results":[]}

View file

@ -0,0 +1 @@
{"movie_results":[],"person_results":[],"tv_results":[],"tv_episode_results":[{"id":1305551,"name":"迷你剧下","overview":"40年的和平结束人类遭受到赛昂人灭绝种族式的攻击幸存者被迫逃离他们的12个殖民地。","media_type":"tv_episode","vote_average":8.1,"vote_count":16,"air_date":"2003-12-09","episode_number":2,"production_code":"","runtime":90,"season_number":1,"show_id":71365,"still_path":"/77kEx9Zw6yI69oCrffQc1hIGOjC.jpg"}],"tv_season_results":[]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long