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")
GoogleBooks = "googlebooks", _("谷歌图书")
BooksTW = "bookstw", _("博客来")
IMDB = "imdb", _("IMDB")
TMDB = "tmdb", _("The Movie Database")
IMDB = "imdb", _("IMDb")
TMDB = "tmdb", _("TMDB")
Bandcamp = "bandcamp", _("Bandcamp")
Spotify = "spotify", _("Spotify")
IGDB = "igdb", _("IGDB")

View file

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

View file

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

View file

@ -11,6 +11,13 @@ _logger = logging.getLogger(__name__)
@SiteManager.register
class IMDB(AbstractSite):
"""
IMDb site manager
IMDB ids map to Movie, TVShow or TVEpisode
IMDB
"""
SITE_NAME = SiteName.IMDB
ID_TYPE = IdType.IMDB
URL_PATTERNS = [
@ -25,6 +32,8 @@ class IMDB(AbstractSite):
def scrape(self):
res_data = search_tmdb_by_imdb_id(self.id_value)
url = None
pd = None
if (
"movie_results" in res_data
and len(res_data["movie_results"]) > 0
@ -46,21 +55,15 @@ class IMDB(AbstractSite):
tv_id = res_data["tv_episode_results"][0]["show_id"]
season_number = res_data["tv_episode_results"][0]["season_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}"
elif episode_number == 1:
url = f"https://www.themoviedb.org/tv/{tv_id}/season/{season_number}"
else:
raise ParseError(
self,
"IMDB id matching TMDB but not first episode, this is not supported",
)
else:
# IMDB id not found in TMDB use real IMDB scraper
return self.scrape_imdb()
if url:
tmdb = SiteManager.get_site_by_url(url)
pd = tmdb.scrape()
pd.metadata["preferred_model"] = tmdb.DEFAULT_MODEL.__name__
pd.metadata["required_resources"] = [] # do not auto fetch parent season
if not pd:
# if IMDB id not found in TMDB, use real IMDB scraper
pd = self.scrape_imdb()
return pd
def scrape_imdb(self):
@ -81,9 +84,17 @@ class IMDB(AbstractSite):
if d.get("primaryImage")
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/)
data["preferred_model"] = (
"" # "TVSeason" not supported yet
"TVEpisode"
if data["is_episode"]
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"]}'
)
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")
pd.lookup_ids[IdType.IMDB] = d2["external_ids"].get("imdb_id")
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 duration %}
{% 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 %}
{% if forloop.counter <= 10 %}
<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 %}
{% if item.child_class %}
<details>
<summary>{% trans '建子条目' %}</summary>
<summary>{% trans '建子条目' %}</summary>
<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 }}">
</form>
</details>
{% 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" %}
<details>
<summary>{% trans '切换分类' %}</summary>

View file

@ -365,24 +365,45 @@
{% block content %}{% endblock %}
</section>
<section class="solo-hidden">
{% if request.user.is_authenticated %}
<div id="comment-default">
<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>
| <a href="{% url 'catalog:mark_list' item.url_path item.uuid 'following' %}">{% trans '关注的人的标记' %}</a>
</small>
{% endif %}
</h5>
{% if request.user.is_authenticated %}
<div>
<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>
{% 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 %}
<h5>短评</h5>
<p class="empty">登录后可见</p>
{% endif %}
</section>

View file

@ -62,27 +62,7 @@
{% empty %}
<div>{% trans '暂无标记' %}</div>
{% endfor %}
<div class="pagination">
{% 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>
{% include "_pagination.html" %}
</div>
<aside class="grid__aside top">
{% include "_sidebar_item.html" %}

View file

@ -56,27 +56,7 @@
{% empty %}
<div>{% trans '暂无评论' %}</div>
{% endfor %}
<div class="pagination">
{% 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>
{% include "_pagination.html" %}
</div>
<aside class="grid__aside top">
{% include "_sidebar_item.html" %}

View file

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

View file

@ -231,6 +231,7 @@ class TVSeason(Item):
category = ItemCategory.TV
url_path = "tv/season"
demonstrative = _("这季剧集")
child_class = "TVEpisode"
douban_movie = PrimaryLookupIdDescriptor(IdType.DoubanMovie)
imdb = PrimaryLookupIdDescriptor(IdType.IMDB)
tmdb_tvseason = PrimaryLookupIdDescriptor(IdType.TMDB_TVSeason)
@ -391,16 +392,38 @@ class TVSeason(Item):
def set_parent_item(self, value):
self.show = value
@property
def child_items(self):
return self.episodes.all()
class TVEpisode(Item):
category = ItemCategory.TV
url_path = "tv/episode"
show = models.ForeignKey(
TVShow, null=True, on_delete=models.SET_NULL, related_name="episodes"
)
season = models.ForeignKey(
TVSeason, null=True, on_delete=models.SET_NULL, related_name="episodes"
)
season_number = jsondata.IntegerField(null=True)
episode_number = models.PositiveIntegerField(null=True)
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 catalog.common import *
from catalog.tv.models import *
from catalog.sites.imdb import IMDB
class JSONFieldTestCase(TestCase):
@ -76,6 +77,25 @@ class TMDBTVSeasonTestCase(TestCase):
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):
@use_local_response
def test_scrape(self):
@ -115,15 +135,15 @@ class MultiTVSitesTestCase(TestCase):
@use_local_response
def test_tvseasons(self):
url1 = "https://www.themoviedb.org/tv/57243-doctor-who/season/4"
url2 = "https://www.imdb.com/title/tt1159991/"
url3 = "https://movie.douban.com/subject/3627919/"
url2 = "https://movie.douban.com/subject/3627919/"
url3 = "https://www.imdb.com/title/tt1159991/"
p1 = SiteManager.get_site_by_url(url1).get_resource_ready()
p2 = SiteManager.get_site_by_url(url2).get_resource_ready()
p3 = SiteManager.get_site_by_url(url3).get_resource_ready()
self.assertEqual(p1.item.imdb, p2.item.imdb)
self.assertEqual(p2.item.imdb, p3.item.imdb)
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
def test_miniseries(self):
@ -161,3 +181,89 @@ class MovieTVModelRecastTestCase(TestCase):
movie = tv.recast_to(Movie)
self.assertEqual(movie.class_name, "movie")
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,
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(
r"^(?P<item_path>"
+ _get_all_url_paths()
@ -89,7 +96,14 @@ urlpatterns = [
re_path(
r"^(?P<item_path>"
+ _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,
name="comments",
),

View file

@ -8,6 +8,7 @@ from django.db.models import Count
from django.utils import timezone
from django.core.paginator import Paginator
from catalog.common.models import ExternalResource, IdType, IdealIdTypes
from catalog.sites.imdb import IMDB
from .models import *
from django.views.decorators.clickjacking import xframe_options_exempt
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)
@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
def merge(request, item_path, item_uuid):
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)
page_number = request.GET.get("page", default=1)
marks = paginator.get_page(page_number)
marks.pagination = PageLinksGenerator(
PAGE_LINK_NUMBER, page_number, paginator.num_pages
)
pagination = PageLinksGenerator(PAGE_LINK_NUMBER, page_number, paginator.num_pages)
return render(
request,
"item_mark_list.html",
@ -372,6 +383,7 @@ def mark_list(request, item_path, item_uuid, following_only=False):
"marks": marks,
"item": item,
"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)
page_number = request.GET.get("page", default=1)
reviews = paginator.get_page(page_number)
reviews.pagination = PageLinksGenerator(
PAGE_LINK_NUMBER, page_number, paginator.num_pages
)
pagination = PageLinksGenerator(PAGE_LINK_NUMBER, page_number, paginator.num_pages)
return render(
request,
"item_review_list.html",
{
"reviews": reviews,
"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
def reviews(request, item_path, 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;
padding: 0 0.25rem;
border: var(--pico-primary) solid 1px;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
&.current {
color: var(--pico-form-element-background-color);
background: var(--pico-primary);
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
html = f'<span class="rating-star" data-rating="{v}"><div style="width:{pct}%;"></div></span>'
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
"""
def __init__(self, length, current_page, total_pages):
def __init__(self, length: int, current_page: int, total_pages: int):
current_page = int(current_page)
self.current_page = current_page
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.start_page = None
self.end_page = None
self.start_page = 1
self.end_page = 1
self.page_range = None
self.has_prev = None
self.has_next = None

View file

@ -321,6 +321,7 @@ class Review(Content):
MIN_RATING_COUNT = 5
RATING_INCLUDES_CHILD_ITEMS = ["tvshow", "performance"]
class Rating(Content):
@ -333,20 +334,48 @@ class Rating(Content):
@staticmethod
def get_rating_for_item(item):
ids = item.child_item_ids + [item.id]
stat = Rating.objects.filter(item_id__in=ids, grade__isnull=False).aggregate(
average=Avg("grade"), count=Count("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.aggregate(average=Avg("grade"), count=Count("item"))
return round(stat["average"], 1) if stat["count"] >= MIN_RATING_COUNT else None
@staticmethod
def get_rating_count_for_item(item):
ids = item.child_item_ids + [item.id]
stat = Rating.objects.filter(item_id__in=ids, grade__isnull=False).aggregate(
count=Count("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.aggregate(count=Count("item"))
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
def rate_item_by_user(item, user, rating_grade, visibility=0):
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()
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_count = property(Rating.get_rating_count_for_item)
@ -601,8 +606,9 @@ ShelfTypeNames = [
[ItemCategory.Podcast, ShelfType.WISHLIST, _("想听")],
[ItemCategory.Podcast, ShelfType.PROGRESS, _("在听")],
[ItemCategory.Podcast, ShelfType.COMPLETE, _("听过")],
# disable all shelves for PodcastEpisode
[ItemCategory.Performance, ShelfType.WISHLIST, _("想看")],
# disable progress shelf for performance
# disable progress shelf for Performance
[ItemCategory.Performance, ShelfType.PROGRESS, _("")],
[ItemCategory.Performance, ShelfType.COMPLETE, _("看过")],
]

View file

@ -13,15 +13,14 @@
<div class="modal-underlay" _="on click trigger closeModal"></div>
<div class="modal-content" style="font-size: 80%;">
<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"
_="on click trigger closeModal">
<i class="fa-solid fa-xmark"></i>
</span>
</div>
<div class="add-to-list-modal__body">
<form action="{% url 'journal:comment' item.uuid focus_item.uuid %}"
method="post">
<form action="{% url 'journal:comment' item.uuid %}" method="post">
{% csrf_token %}
<textarea name="text"
cols="40"
@ -85,7 +84,7 @@
<div class="mark-modal__confirm-button">
<input type="submit" class="button float-right" value="保存">
</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>
<input type="checkbox" name="share_position" value="1" id="share_position">

View file

@ -22,6 +22,7 @@
<div class="add-to-list-modal__body">
<form method="post" action="{% url 'journal:mark' item.uuid %}">
{% csrf_token %}
{% if shelf_types %}
<div class="grid mark-line">
<div>
<fieldset>
@ -59,6 +60,7 @@
</span>
</div>
</div>
{% endif %}
<div>
<fieldset>
<textarea name="text"

View file

@ -28,29 +28,7 @@
<div>{% trans '无结果' %}</div>
{% endfor %}
</div>
<div class="pagination">
{% 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>
{% include "_pagination.html" %}
</div>
{% include "_sidebar.html" with show_profile=1 %}
</main>

View file

@ -21,7 +21,7 @@ urlpatterns = [
path("like/<str:piece_uuid>", like, name="like"),
path("unlike/<str:piece_uuid>", unlike, name="unlike"),
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(
"add_to_collection/<str:item_uuid>", add_to_collection, name="add_to_collection"
),

View file

@ -195,60 +195,21 @@ def mark(request, item_uuid):
raise BadRequest()
@login_required
def comment(request, item_uuid, focus_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":
return render(
request,
"comment.html",
{
"item": item,
"focus_item": focus_item,
"comment": comment,
},
)
elif request.method == "POST":
if request.POST.get("delete", default=False):
if not comment:
raise Http404()
comment.delete()
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
visibility = int(request.POST.get("visibility", default=0))
text = request.POST.get("text")
position = (
request.POST.get("position")
if request.POST.get("share_position")
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
def post_comment(user, item, text, visibility, shared_link=None, position=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}"
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(
request.user.mastodon_site,
user.mastodon_site,
status,
get_visibility(visibility, request.user),
request.user.mastodon_token,
get_visibility(visibility, user),
user.mastodon_token,
False,
status_id,
spoiler,
@ -261,22 +222,106 @@ def comment(request, item_uuid, focus_item_uuid):
if settings.DEBUG:
raise
post_error = True
if comment:
comment.visibility = visibility
comment.text = text
comment.metadata["position"] = position
if shared_link:
comment.metadata["shared_link"] = shared_link
comment.save()
else:
comment = Comment.objects.create(
return post_error, shared_link
@login_required
def comment_select_episode(request, item_uuid):
item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid))
if request.method == "GET":
return render(
request,
"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,
"comment": comment,
},
)
elif request.method == "POST":
if request.POST.get("delete", default=False):
if not comment:
raise Http404()
comment.delete()
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
visibility = int(request.POST.get("visibility", default=0))
text = request.POST.get("text")
position = None
if item.class_name == "podcastepisode":
position = request.POST.get("position") or "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 = comment.metadata.get("shared_link") if comment else None
post_error = False
if share_to_mastodon:
post_error, shared_link = post_comment(
request.user, item, text, visibility, shared_link, position
)
Comment.objects.update_or_create(
owner=request.user,
item=item,
focus_item=focus_item,
text=text,
visibility=visibility,
metadata={"shared_link": shared_link, "position": position},
# 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:
return render_relogin(request)
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
@ -648,11 +693,11 @@ def _render_list(
paginator = Paginator(queryset, PAGE_SIZE)
page_number = request.GET.get("page", default=1)
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(
request,
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):
""" """
MarkItem = "mark_item"
ReviewItem = "review_item"
CreateCollection = "create_collection"
LikeCollection = "like_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):
@ -62,7 +61,7 @@ class ActivityManager:
return (
LocalActivity.objects.filter(q)
.order_by("-created_time")
.prefetch_related("action_object")
.prefetch_related("action_object", "owner")
) # .select_related() https://github.com/django-polymorphic/django-polymorphic/pull/531
@staticmethod
@ -70,8 +69,8 @@ class ActivityManager:
return ActivityManager(user)
User.activity_manager = cached_property(ActivityManager.get_manager_for_user)
User.activity_manager.__set_name__(User, "activity_manager")
User.activity_manager = cached_property(ActivityManager.get_manager_for_user) # type: ignore
User.activity_manager.__set_name__(User, "activity_manager") # type: ignore
class DataSignalManager:
@ -184,17 +183,31 @@ class FeaturedCollectionProcessor(DefaultActivityProcessor):
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
class CommentFocusItemProcessor(DefaultActivityProcessor):
class CommentChildItemProcessor(DefaultActivityProcessor):
model = Comment
template = ActivityTemplate.CommentFocusItem
template = ActivityTemplate.CommentChildItem
def created(self):
if self.action_object.focus_item:
if self.action_object.item.class_name in ["podcastepisode", "tvepisode"]:
super().created()
def updated(self):
if self.action_object.focus_item:
if self.action_object.item.class_name in ["podcastepisode", "tvepisode"]:
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 %}
{% wish_item_action activity.action_object.item as 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 %}
<span>
{% liked_piece activity.action_object.mark.comment as liked %}
@ -47,6 +39,9 @@
</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 }}

View file

@ -11,28 +11,10 @@
{% load duration %}
{% wish_item_action activity.action_object.item as 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>
{% liked_piece activity.action_object as liked %}
{% include 'like_stats.html' with liked=liked piece=activity.action_object %}
</span>
{% comment %}
<span>
<a><i class="fa-solid fa-circle-play"></i></a>
</span>
{% endcomment %}
<span>
{% if not action.taken %}
<a title="添加标记"
@ -50,6 +32,9 @@
</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">
写了评论

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