backup for Phase 2.5

This commit is contained in:
JakeBreath
2026-08-04 00:34:02 -05:00
parent a81e31bf1a
commit 8c13c5e5ec
9 changed files with 745 additions and 11 deletions
@@ -0,0 +1,23 @@
# Generated by Django 6.0.3 on 2026-08-04 04:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0006_version_mods_manifest_version_pack_description_and_more'),
]
operations = [
migrations.AddField(
model_name='version',
name='animation_id',
field=models.CharField(blank=True, db_index=True, default='', max_length=128),
),
migrations.AddField(
model_name='version',
name='animation_manifest',
field=models.JSONField(blank=True, default=dict),
),
]
+2
View File
@@ -209,6 +209,8 @@ class Version(models.Model):
pack_format = models.IntegerField(null=True, blank=True)
pack_description = models.TextField(blank=True, default='')
mods_manifest = models.JSONField(default=dict, blank=True)
animation_id = models.CharField(max_length=128, blank=True, default='', db_index=True)
animation_manifest = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
+178
View File
@@ -1142,3 +1142,181 @@ class UGCCategoryTests(UGCMediaTestCase, UGCGatedTestCase):
version = self.project.versions.first()
self.assertIsNone(version.pack_format)
self.assertEqual(version.mods_manifest, {})
def _animation_pack_zip(with_id=True, entity_x_player=False, problem=True):
actor_a = {
'label': 'actor1', 'entity_types': ['minecraft:player'],
'actor_tags': ['gender.male'], 'activity': 'active', 'injector': 'V',
}
actor_b = {
'label': 'actor2',
'entity_types': ['minecraft:zombie'] if entity_x_player else ['minecraft:player'],
'actor_tags': [] if entity_x_player else ['gender.female'],
'activity': 'passive', 'receiver': True,
}
content_tags = ['missionary'] + (['bugged'] if problem else [])
af = {
'id': 'jakebreath:testpack', 'name': 'Test Pack', 'author': 'Alice, Bob',
'version': '1.0.0', 'description': 'A test pack.',
} if with_id else {'name': 'Test Pack', 'author': 'Alice'}
return _zip_bytes({
'pack.mcmeta': json.dumps({'pack': {'pack_format': 64}, 'animationframework': af}),
'data/jakebreath/afw_animdefs/ground.json': json.dumps({
'display_name': 'Ground', 'content_tags': content_tags,
'actors': [actor_a, actor_b],
'stages': [{'stage': 1, 'loop': True, 'cycle_seconds': 1.0},
{'stage': 2, 'loop': False, 'non_peak': True}],
}),
})
def _bom_pack_zip():
mcmeta = b'\xef\xbb\xbf' + json.dumps({
'pack': {'pack_format': 64},
'animationframework': {'id': 'needsofnature:default', 'name': 'Default', 'author': 'NoN Team'},
}).encode()
return _zip_bytes({
'pack.mcmeta': mcmeta,
'data/needsofnature/afw_animdefs/solo.json': json.dumps({
'actors': [{'label': 'actor1', 'entity_types': ['minecraft:player'], 'activity': 'passive'}],
'content_tags': ['solo'],
'stages': [{'stage': 1, 'loop': True}],
}),
})
class UGCAnimationTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
def _write_zip(self, data):
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as f:
f.write(data)
return f.name
def _upload_version(self, slug, name, content):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile(name, content, content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 200)
return self.client.post(
reverse('library:version_upload', args=[slug]), {'version_name': '1.0.0'},
)
def test_read_animation_manifest_player_x_player(self):
from library.zips import read_animation_manifest
path = self._write_zip(_animation_pack_zip())
try:
manifest = read_animation_manifest(path)
finally:
os.unlink(path)
self.assertEqual(manifest['animation_id'], 'jakebreath:testpack')
self.assertEqual(manifest['authors'], ['Alice', 'Bob'])
anim = manifest['animations'][0]
self.assertEqual(anim['name'], 'Ground')
self.assertEqual(anim['type'], 'Pair')
self.assertEqual(anim['content_tags'], ['missionary', 'bugged'])
self.assertEqual(anim['problem_tags'], ['bugged'])
self.assertEqual(anim['actors'][0]['injector'], 'V')
self.assertEqual(anim['actors'][0]['injector_name'], 'Vaginal')
self.assertEqual(anim['actors'][1]['gender'], 'female')
self.assertEqual(anim['stages'][1]['climax'], True)
def test_read_animation_manifest_entity_x_player(self):
from library.zips import read_animation_manifest
path = self._write_zip(_animation_pack_zip(entity_x_player=True))
try:
manifest = read_animation_manifest(path)
finally:
os.unlink(path)
anim = manifest['animations'][0]
self.assertEqual(anim['type'], 'Entity x Player')
self.assertEqual(anim['actors'][1]['entity'], 'Zombie')
self.assertEqual(anim['entity_names'], ['Zombie'])
def test_read_animation_manifest_bom_mcmeta(self):
from library.zips import read_animation_manifest, read_pack_mcmeta
path = self._write_zip(_bom_pack_zip())
try:
fmt, desc = read_pack_mcmeta(path)
manifest = read_animation_manifest(path)
finally:
os.unlink(path)
self.assertEqual(fmt, 64)
self.assertEqual(manifest['animation_id'], 'needsofnature:default')
self.assertEqual(manifest['animations'][0]['type'], 'Solo')
def test_read_animation_manifest_not_animation(self):
from library.zips import read_animation_manifest
path = self._write_zip(_zip_bytes({'data/x.txt': 'x'}))
try:
manifest = read_animation_manifest(path)
finally:
os.unlink(path)
self.assertIsNone(manifest)
def test_version_upload_captures_manifest_and_tags(self):
self.project = Project.objects.create(
slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice,
)
resp = self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip())
self.assertRedirects(resp, reverse('library:project_detail', args=['anim-pack']))
version = self.project.versions.first()
self.assertEqual(version.animation_id, 'jakebreath:testpack')
self.assertEqual(version.animation_manifest['animations'][0]['name'], 'Ground')
# Auto-created content tags (including the problem tag).
names = set(TagList.objects.filter(project=self.project).values_list('tag__name', flat=True))
self.assertIn('missionary', names)
self.assertIn('bugged', names)
def test_version_upload_warns_without_animation_id(self):
self.project = Project.objects.create(
slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice,
)
resp = self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip(with_id=False))
self.assertRedirects(resp, reverse('library:project_detail', args=['anim-pack']),
fetch_redirect_response=False)
version = self.project.versions.first()
self.assertEqual(version.animation_id, '')
self.assertTrue(version.animation_manifest)
# The warning banner renders on the redirect target (before it's consumed).
resp = self.client.get(reverse('library:project_detail', args=['anim-pack']))
self.assertContains(resp, 'incompatible with the update-check API')
def test_latest_api_returns_version_and_url(self):
self.project = Project.objects.create(
slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice,
)
self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip())
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.get(reverse('library:api_packs_latest', args=['jakebreath', 'testpack']))
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data['version'], '1.0.0')
self.assertEqual(data['pack_format'], 64)
self.assertIn('/api/files/', data['download_url'])
self.assertIn('?download=1', data['download_url'])
def test_latest_api_404_unknown_pack(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.get(reverse('library:api_packs_latest', args=['nope', 'missing']))
self.assertEqual(resp.status_code, 404)
def test_latest_api_404_without_animation_id(self):
self.project = Project.objects.create(
slug='anim-pack', title='Anim Pack', category='non_pack', owner=self.alice,
)
self._upload_version('anim-pack', 'pack.zip', _animation_pack_zip(with_id=False))
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.get(reverse('library:api_packs_latest', args=['jakebreath', 'testpack']))
self.assertEqual(resp.status_code, 404)
+1
View File
@@ -22,5 +22,6 @@ urlpatterns = [
path('api/uploads/<uuid:upload_uuid>/delete/', views.api_delete_temp, name='api_delete_temp'),
path('api/draft/', views.api_draft, name='api_draft'),
path('api/tags/autocomplete/', views.api_tags_autocomplete, name='api_tags_autocomplete'),
path('api/packs/<str:namespace>/<str:pack_id>/latest', views.api_packs_latest, name='api_packs_latest'),
path('api/users/autocomplete/', views.api_users_autocomplete, name='api_users_autocomplete'),
]
+107 -10
View File
@@ -30,7 +30,7 @@ from .models import (
slugify_tag,
)
from .storage import delete_file_index, move_file_index, store_temp_file
from .zips import parse_mods_manifest, read_pack_mcmeta
from .zips import parse_mods_manifest, read_animation_manifest, read_pack_mcmeta
def _open_indexed_file(file_index):
@@ -316,10 +316,24 @@ def project_detail(request, slug):
can_edit = project.can_edit(request.user)
guide_docs = []
if project.category == 'guide':
latest = versions.first()
if latest is not None:
guide_docs = [vf for vf in latest.files.all() if vf.is_markdown]
animation_manifest = None
anim_stats = None
latest = versions.first()
if project.category == 'guide' and latest is not None:
guide_docs = [vf for vf in latest.files.all() if vf.is_markdown]
if latest is not None and latest.animation_manifest:
animation_manifest = latest.animation_manifest
anims = animation_manifest.get('animations') or []
entity_count = sum(1 for a in anims if a.get('type') == 'Entity x Player')
solo_count = sum(1 for a in anims if a.get('type') == 'Solo')
anim_stats = {
'total': len(anims),
'entity': entity_count,
'player': len(anims) - entity_count - solo_count,
'solo': solo_count,
'flagged': sum(1 for a in anims if a.get('problem_tags')),
'tags': sorted({t for a in anims for t in (a.get('content_tags') or [])}),
}
context = {
'project': project,
@@ -328,10 +342,43 @@ def project_detail(request, slug):
'tags': tags,
'can_edit': can_edit,
'guide_docs': guide_docs,
'animation_manifest': animation_manifest,
'anim_stats': anim_stats,
}
return render(request, 'library/project_detail.html', context)
@login_required
def api_packs_latest(request, namespace, pack_id):
"""Auto-updater endpoint: newest version for a NoN pack id (namespace:pack_id)."""
animation_id = f'{namespace}:{pack_id}'
version = (
Version.objects.filter(animation_id=animation_id)
.select_related('project').prefetch_related('files__file')
.order_by('-created_at').first()
)
if version is None:
return JsonResponse({'detail': 'Not found'}, status=404)
release = next(
(vf for vf in version.files.all() if vf.kind == 'release'),
version.files.first(),
)
if release is None:
return JsonResponse({'detail': 'No file'}, status=404)
download_url = request.build_absolute_uri(
reverse('library:file_request', args=[release.file.uuid]) + '?download=1'
)
return JsonResponse({
'namespace': namespace,
'pack_id': pack_id,
'name': version.project.title,
'version': version.version_name,
'pack_format': version.pack_format,
'created_at': version.created_at.isoformat(),
'download_url': download_url,
})
def guide_doc(request, slug, version_id, file_uuid):
"""Render one markdown guide document server-side (used by the switcher)."""
version = get_object_or_404(
@@ -410,9 +457,34 @@ def _stored_path(file_index):
return Path(settings.MEDIA_ROOT) / file_index.stored_path
def _finalize_version(project, version_name, changelog, temp_uploads):
ANIMATION_API_WARNING = (
'Warning: your pack is incompatible with the update-check API endpoint and '
'may not work with applications that check for updates. Add an '
"'animationframework' block to pack.mcmeta, e.g.: "
'{"pack":{"pack_format":64},"animationframework":{"id":"yourname:yourpack",'
'"name":"Your Pack","author":"You","version":"1.0.0","description":"..."}}'
)
def _apply_content_tags(project, tag_names, actor):
"""Auto-create a content:<tag> for every distinct animation content tag."""
content_cat = TagCategory.objects.filter(slug='content').first()
if content_cat is None:
return
for name in tag_names:
name = slugify_tag(name)
if not name:
continue
tag, _ = Tag.objects.get_or_create(
name=name, category=content_cat, defaults={'created_by': actor},
)
TagList.objects.get_or_create(project=project, tag=tag, defaults={'added_by': actor})
def _finalize_version(project, version_name, changelog, temp_uploads, actor):
"""Adopt pending 'version' temp uploads into a new Version as VersionFiles,
capturing category-specific metadata (pack.mcmeta / mods manifest)."""
capturing category-specific metadata (pack.mcmeta / mods manifest / animation
manifest). Returns (version, missing_animation_id)."""
version = Version.objects.create(
project=project,
version_name=version_name,
@@ -421,6 +493,8 @@ def _finalize_version(project, version_name, changelog, temp_uploads):
pack_format = None
pack_description = ''
mods_manifest = {}
animation_manifest = None
missing_animation_id = False
for temp in temp_uploads:
index = _adopt_temp(temp, project.pk, 'versions', 'version')
@@ -442,6 +516,12 @@ def _finalize_version(project, version_name, changelog, temp_uploads):
manifest = parse_mods_manifest(_stored_path(index), index.original_filename)
if manifest.get('files'):
mods_manifest = manifest
if is_zip and animation_manifest is None:
manifest = read_animation_manifest(_stored_path(index))
if manifest is not None:
animation_manifest = manifest
if not manifest.get('animation_id'):
missing_animation_id = True
update_fields = []
if pack_format is not None:
@@ -451,9 +531,20 @@ def _finalize_version(project, version_name, changelog, temp_uploads):
if mods_manifest:
version.mods_manifest = mods_manifest
update_fields.append('mods_manifest')
if animation_manifest is not None:
version.animation_manifest = animation_manifest
update_fields.append('animation_manifest')
if animation_manifest.get('animation_id'):
version.animation_id = animation_manifest['animation_id']
update_fields.append('animation_id')
if update_fields:
version.save(update_fields=update_fields)
return version
if animation_manifest is not None:
_apply_content_tags(
project, animation_manifest.get('content_tags') or [], actor,
)
return version, missing_animation_id
def _form_values(*forms):
@@ -494,12 +585,15 @@ def project_create(request):
thumb.save(update_fields=['status'])
project.save(update_fields=['thumbnail'])
_finalize_version(
_, missing_animation_id = _finalize_version(
project,
version_form.cleaned_data['version_name'],
version_form.cleaned_data['changelog'],
version_files,
request.user,
)
if missing_animation_id:
messages.warning(request, ANIMATION_API_WARNING)
for temp in TempUpload.objects.filter(
user=request.user, status='pending', kind='media',
@@ -631,12 +725,15 @@ def version_upload(request, slug):
if not version_files:
form.add_error('version_name', 'Upload at least one version file before uploading.')
else:
_finalize_version(
_, missing_animation_id = _finalize_version(
project,
form.cleaned_data['version_name'],
form.cleaned_data['changelog'],
version_files,
request.user,
)
if missing_animation_id:
messages.warning(request, ANIMATION_API_WARNING)
messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.')
return redirect('library:project_detail', slug=project.slug)
+167 -1
View File
@@ -8,6 +8,14 @@ adopted file (under MEDIA_ROOT) and return plain dicts/values.
import json
import zipfile
INJECTOR_NAMES = {'V': 'Vaginal', 'M': 'Mouth', 'A': 'Anal'}
PROBLEM_TAGS = {'broken', 'bugged', 'borked'}
def _decode(data):
"""Decode JSON text, tolerating a UTF-8 BOM (common in hand-edited packs)."""
return json.loads(data.decode('utf-8-sig', 'replace'))
def _open_zip(path):
"""Return a ZipFile for the path (None when it's not a zip)."""
@@ -27,7 +35,7 @@ def read_pack_mcmeta(path):
return None, ''
try:
try:
data = json.loads(zf.read('pack.mcmeta').decode('utf-8', 'replace'))
data = _decode(zf.read('pack.mcmeta'))
except (KeyError, json.JSONDecodeError):
return None, ''
pack = data.get('pack') or {}
@@ -43,6 +51,164 @@ def read_pack_mcmeta(path):
zf.close()
def _friendly_entity(entity):
"""minecraft:zombie -> Zombie; needsofnature:horse_liquid_collector -> Horse Liquid Collector."""
name = entity.rsplit(':', 1)[-1]
return name.replace('_', ' ').title()
def _gender(actor_tags):
for tag in actor_tags or []:
if str(tag).startswith('gender.'):
return str(tag).split('.', 1)[1]
return None
def _summarize_animdef(filename, data):
stem = filename.rsplit('/', 1)[-1]
if stem.endswith('.json'):
stem = stem[:-5]
display = data.get('display_name') or stem.replace('_', ' ').title()
participants = len(data.get('actors') or [])
actors = []
has_entity = False
entity_names = []
for actor in data.get('actors') or []:
entity_types = actor.get('entity_types') or []
entity = 'player'
if entity_types:
non_players = [e for e in entity_types if e != 'minecraft:player']
if non_players:
entity = _friendly_entity(non_players[0])
has_entity = True
entity_names.append(entity)
injector = actor.get('injector')
inj = None
inj_name = None
if injector is True:
inj = 'injector'
inj_name = 'injector'
elif isinstance(injector, str) and injector:
inj = injector
inj_name = INJECTOR_NAMES.get(injector, injector)
actors.append({
'label': actor.get('label') or '',
'entity': entity,
'gender': _gender(actor.get('actor_tags')),
'activity': actor.get('activity') or '',
'injector': inj,
'injector_name': inj_name,
'receiver': bool(actor.get('receiver')),
})
content_tags = [t for t in data.get('content_tags') or []]
animation_tags = data.get('animation_tags') or []
problem_tags = sorted({
t for t in list(content_tags) + list(animation_tags)
if str(t).lower() in PROBLEM_TAGS
})
if participants == 1:
type_ = 'Solo'
elif has_entity:
type_ = 'Entity x Player'
elif participants == 2:
type_ = 'Pair'
elif participants == 3:
type_ = 'Threesome'
else:
type_ = f'Group of {participants}'
stages = []
for stage in data.get('stages') or []:
stages.append({
'stage': stage.get('stage'),
'loop': bool(stage.get('loop')),
'cycle_seconds': stage.get('cycle_seconds'),
'speed': stage.get('speed'),
'climax': bool(stage.get('non_peak') or stage.get('manual_peak')),
'use_stage': stage.get('use_stage'),
'joinable': bool(stage.get('allow_join', True)),
'escapable': bool(stage.get('escapable', True)),
})
return {
'name': display,
'content_tags': content_tags,
'animation_tags': list(animation_tags),
'type': type_,
'entity_names': entity_names,
'participants': participants,
'actors': actors,
'stages': stages,
'weight': data.get('weight'),
'block_requirements': bool(data.get('block_requirements')),
'water': bool(data.get('water')),
'problem_tags': problem_tags,
}
def read_animation_manifest(path):
"""Build the animation manifest for a NoN animation pack.
Detected by the presence of data/*/afw_animdefs/*.json. Returns None when
the archive isn't an animation pack. Reads the animationframework block
(id/name/version/authors/description) plus a summary of every animdef.
"""
zf = _open_zip(path)
if zf is None:
return None
try:
names = set(zf.namelist())
animdef_names = sorted(
n for n in names if '/afw_animdefs/' in n and n.endswith('.json')
)
if not animdef_names:
return None
animation_id = None
pack_name = ''
pack_version = ''
description = ''
authors = []
try:
mcmeta = _decode(zf.read('pack.mcmeta'))
af = mcmeta.get('animationframework') or {}
animation_id = af.get('id') or None
pack_name = str(af.get('name') or '')
pack_version = str(af.get('version') or '')
description = str(af.get('description') or '')
author = af.get('author')
if author:
authors = [a.strip() for a in str(author).split(',') if a.strip()]
except (KeyError, json.JSONDecodeError):
pass
animations = []
distinct_content_tags = set()
for name in animdef_names:
try:
data = _decode(zf.read(name))
except (KeyError, json.JSONDecodeError):
continue
summary = _summarize_animdef(name, data)
distinct_content_tags.update(summary['content_tags'])
animations.append(summary)
return {
'animation_id': animation_id,
'name': pack_name,
'version': pack_version,
'authors': authors,
'description': description,
'animations': animations,
'content_tags': sorted(distinct_content_tags),
}
finally:
zf.close()
def parse_mods_manifest(path, filename=''):
"""Inspect a modpack archive and build a manifest for the Mods tab.