diff --git a/catalog/templates/tvepisode.html b/catalog/templates/tvepisode.html
new file mode 100644
index 00000000..6ee5ff36
--- /dev/null
+++ b/catalog/templates/tvepisode.html
@@ -0,0 +1,15 @@
+{% extends "item_base.html" %}
+{% load static %}
+{% load i18n %}
+{% load l10n %}
+{% load humanize %}
+{% load admin_url %}
+{% load mastodon %}
+{% load oauth_token %}
+{% load truncate %}
+{% load strip_scheme %}
+{% load thumb %}
+{% block head %}
+
+{% endblock %}
diff --git a/catalog/views_edit.py b/catalog/views_edit.py
new file mode 100644
index 00000000..a56b2c07
--- /dev/null
+++ b/catalog/views_edit.py
@@ -0,0 +1,250 @@
+import logging
+from django.shortcuts import render, get_object_or_404, redirect
+from django.contrib.auth.decorators import login_required, permission_required
+from django.utils.translation import gettext_lazy as _
+from django.http import HttpResponseRedirect
+from django.core.exceptions import BadRequest, PermissionDenied, ObjectDoesNotExist
+from django.utils import timezone
+from django.contrib import messages
+from .common.models import ExternalResource, IdType, IdealIdTypes
+from .sites.imdb import IMDB
+from .models import *
+from .forms import *
+from .search.views import *
+from journal.models import update_journal_for_merged_item
+from common.utils import get_uuid_or_404
+
+_logger = logging.getLogger(__name__)
+
+
+def _add_error_map_detail(e):
+ e.additonal_detail = []
+ for f, v in e.as_data().items():
+ for validation_error in v:
+ if hasattr(validation_error, "error_map") and validation_error.error_map:
+ for f2, v2 in validation_error.error_map.items():
+ e.additonal_detail.append(f"{f}§{f2}: {'; '.join(v2)}")
+ return e
+
+
+@login_required
+def create(request, item_model):
+ form_cls = CatalogForms.get(item_model)
+ if not form_cls:
+ raise BadRequest("Invalid item type")
+ if request.method == "GET":
+ form = form_cls(
+ initial={
+ "title": request.GET.get("title", ""),
+ }
+ )
+ return render(
+ request,
+ "catalog_edit.html",
+ {
+ "form": form,
+ },
+ )
+ elif request.method == "POST":
+ form = form_cls(request.POST, request.FILES)
+ parent = None
+ if request.GET.get("parent", ""):
+ parent = get_object_or_404(
+ Item, uid=get_uuid_or_404(request.GET.get("parent", ""))
+ )
+ if parent.child_class != form.instance.__class__.__name__:
+ raise BadRequest(
+ f"Invalid parent type: {form.instance.__class__} -> {parent.__class__}"
+ )
+ if form.is_valid():
+ form.instance.last_editor = request.user
+ form.instance.edited_time = timezone.now()
+ if parent:
+ form.instance.set_parent_item(parent)
+ form.instance.save()
+ return redirect(form.instance.url)
+ else:
+ raise BadRequest(_add_error_map_detail(form.errors))
+ else:
+ raise BadRequest("Invalid request method")
+
+
+@login_required
+def history(request, item_path, item_uuid):
+ if request.method == "GET":
+ item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid))
+ return render(request, "catalog_history.html", {"item": item})
+ else:
+ raise BadRequest()
+
+
+@login_required
+def edit(request, item_path, item_uuid):
+ if request.method == "GET":
+ item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid))
+ form_cls = CatalogForms[item.__class__.__name__]
+ form = form_cls(instance=item)
+ if (
+ item.external_resources.all().count() > 0
+ and item.primary_lookup_id_value
+ and item.primary_lookup_id_type in IdealIdTypes
+ ):
+ form.fields["primary_lookup_id_type"].disabled = True
+ form.fields["primary_lookup_id_value"].disabled = True
+ return render(request, "catalog_edit.html", {"form": form, "item": item})
+ elif request.method == "POST":
+ item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid))
+ form_cls = CatalogForms[item.__class__.__name__]
+ form = form_cls(request.POST, request.FILES, instance=item)
+ if (
+ item.external_resources.all().count() > 0
+ and item.primary_lookup_id_value
+ and item.primary_lookup_id_type in IdealIdTypes
+ ):
+ form.fields["primary_lookup_id_type"].disabled = True
+ form.fields["primary_lookup_id_value"].disabled = True
+ if form.is_valid():
+ form.instance.last_editor = request.user
+ form.instance.edited_time = timezone.now()
+ form.instance.save()
+ return redirect(form.instance.url)
+ else:
+ raise BadRequest(_add_error_map_detail(form.errors))
+ else:
+ raise BadRequest()
+
+
+@login_required
+def delete(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 not request.user.is_staff and item.journal_exists():
+ raise PermissionDenied()
+ item.delete()
+ return (
+ redirect(item.url + "?skipcheck=1") if request.user.is_staff else redirect("/")
+ )
+
+
+@login_required
+def recast(request, item_path, item_uuid):
+ if request.method != "POST":
+ raise BadRequest()
+ item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid))
+ cls = request.POST.get("class")
+ # TODO move some of the logic to model
+ douban_movie_to_tvseason = False
+ if cls == "tvshow":
+ if item.external_resources.filter(id_type=IdType.DoubanMovie).exists():
+ cls = "tvseason"
+ douban_movie_to_tvseason = True
+ model = (
+ TVShow
+ if cls == "tvshow"
+ else (Movie if cls == "movie" else (TVSeason if cls == "tvseason" else None))
+ )
+ if not model:
+ raise BadRequest("Invalid target type")
+ if isinstance(item, model):
+ raise BadRequest("Same target type")
+ _logger.warn(f"{request.user} recasting {item} to {model}")
+ if isinstance(item, TVShow):
+ for season in item.seasons.all():
+ _logger.warn(f"{request.user} recast orphaning season {season}")
+ season.show = None
+ season.save(update_fields=["show"])
+ new_item = item.recast_to(model)
+ if douban_movie_to_tvseason:
+ for res in item.external_resources.filter(
+ id_type__in=[IdType.IMDB, IdType.TMDB_TV]
+ ):
+ res.item = None
+ res.save(update_fields=["item"])
+ return redirect(new_item.url)
+
+
+@login_required
+def unlink(request):
+ if request.method != "POST":
+ raise BadRequest()
+ if not request.user.is_staff:
+ raise PermissionDenied()
+ res_id = request.POST.get("id")
+ if not res_id:
+ raise BadRequest()
+ resource = get_object_or_404(ExternalResource, id=res_id)
+ resource.unlink_from_item()
+ return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
+
+
+@login_required
+def assign_parent(request, item_path, item_uuid):
+ if request.method != "POST":
+ raise BadRequest()
+ item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid))
+ parent_item = Item.get_by_url(request.POST.get("parent_item_url"))
+ if not parent_item or parent_item.is_deleted or parent_item.merged_to_item_id:
+ raise BadRequest("Can't assign parent to a deleted or redirected item")
+ if parent_item.child_class != item.__class__.__name__:
+ raise BadRequest("Incompatible child item type")
+ if not request.user.is_staff and item.parent_item:
+ raise BadRequest("Already assigned to a parent item")
+ _logger.warn(f"{request.user} assign {item} to {parent_item}")
+ item.set_parent_item(parent_item)
+ item.save()
+ return redirect(item.url)
+
+
+@login_required
+def remove_unused_seasons(request, item_path, item_uuid):
+ if request.method != "POST":
+ raise BadRequest()
+ item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid))
+ l = list(item.seasons.all())
+ for s in l:
+ if not s.journal_exists():
+ s.delete()
+ l = [s.id for s in l]
+ l2 = [s.id for s in item.seasons.all()]
+ item.log_action({"!remove_unused_seasons": [l, l2]})
+ 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()
+ item.log_action({"!fetch_tvepisodes": ["", ""]})
+ 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":
+ raise BadRequest()
+ item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid))
+ if not request.user.is_staff and item.journal_exists():
+ raise PermissionDenied()
+ if request.POST.get("new_item_url"):
+ new_item = Item.get_by_url(request.POST.get("new_item_url"))
+ if not new_item or new_item.is_deleted or new_item.merged_to_item_id:
+ raise BadRequest(_("不能合并到一个被删除或合并过的条目。"))
+ if new_item.class_name != item.class_name:
+ raise BadRequest(
+ _("不能合并不同类的条目") + f" ({item.class_name} to {new_item.class_name})"
+ )
+ _logger.warn(f"{request.user} merges {item} to {new_item}")
+ item.merge_to(new_item)
+ update_journal_for_merged_item(item_uuid)
+ return redirect(new_item.url)
+ else:
+ if item.merged_to_item:
+ _logger.warn(f"{request.user} cancels merge for {item}")
+ item.merge_to(None)
+ return redirect(item.url)