Skip to content

Commit 739778f

Browse files
committed
add comic
1 parent 712f310 commit 739778f

File tree

10 files changed

+455
-17
lines changed

10 files changed

+455
-17
lines changed

src/app/forms.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,18 @@ class Meta(MediaForm.Meta):
274274
"repeats": "Number of Rereads",
275275
}
276276

277+
class ComicForm(MediaForm):
278+
"""Form for comics."""
279+
280+
class Meta(MediaForm.Meta):
281+
"""Bind form to model."""
282+
283+
model = models.Comic
284+
labels = {
285+
"progress": "Progress (Issues)",
286+
"repeats": "Number of Rereads",
287+
}
288+
277289

278290
class TvForm(MediaForm):
279291
"""Form for TV shows."""

src/app/helpers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def tailwind_to_hex(tailwind_color):
3131

3232
return tailwind_colors.get(tailwind_color)
3333

34+
3435
def minutes_to_hhmm(total_minutes):
3536
"""Convert total minutes to HH:MM format."""
3637
hours = int(total_minutes / 60)
@@ -74,8 +75,9 @@ def get_media_verb(media_type, past_tense):
7475
"movie": ("watch", "watched"),
7576
"anime": ("watch", "watched"),
7677
"manga": ("read", "read"),
77-
"book": ("read", "read"),
7878
"game": ("play", "played"),
79+
"book": ("read", "read"),
80+
"comic": ("read", "read"),
7981
}
8082
return verbs[media_type][1 if past_tense else 0]
8183

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Generated by Django 5.1.7 on 2025-04-05 12:17
2+
3+
import app.mixins
4+
import django.core.validators
5+
import django.db.models.deletion
6+
import simple_history.models
7+
from django.conf import settings
8+
from django.db import migrations, models
9+
10+
11+
class Migration(migrations.Migration):
12+
13+
dependencies = [
14+
('app', '0022_item_source_squashed_0037_alter_item_media_type'),
15+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16+
]
17+
18+
operations = [
19+
migrations.CreateModel(
20+
name='Comic',
21+
fields=[
22+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
23+
('score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.DecimalValidator(3, 1), django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10)])),
24+
('progress', models.PositiveIntegerField(default=0)),
25+
('status', models.CharField(choices=[('Completed', 'Completed'), ('In progress', 'In Progress'), ('Repeating', 'Repeating'), ('Planning', 'Planning'), ('Paused', 'Paused'), ('Dropped', 'Dropped')], default='Completed', max_length=20)),
26+
('repeats', models.PositiveIntegerField(default=0)),
27+
('start_date', models.DateField(blank=True, null=True)),
28+
('end_date', models.DateField(blank=True, null=True)),
29+
('notes', models.TextField(blank=True, default='')),
30+
],
31+
options={
32+
'ordering': ['-score'],
33+
'abstract': False,
34+
},
35+
bases=(app.mixins.CalendarTriggerMixin, models.Model),
36+
),
37+
migrations.CreateModel(
38+
name='HistoricalComic',
39+
fields=[
40+
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
41+
('score', models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True, validators=[django.core.validators.DecimalValidator(3, 1), django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10)])),
42+
('progress', models.PositiveIntegerField(default=0)),
43+
('status', models.CharField(choices=[('Completed', 'Completed'), ('In progress', 'In Progress'), ('Repeating', 'Repeating'), ('Planning', 'Planning'), ('Paused', 'Paused'), ('Dropped', 'Dropped')], default='Completed', max_length=20)),
44+
('repeats', models.PositiveIntegerField(default=0)),
45+
('start_date', models.DateField(blank=True, null=True)),
46+
('end_date', models.DateField(blank=True, null=True)),
47+
('notes', models.TextField(blank=True, default='')),
48+
('history_id', models.AutoField(primary_key=True, serialize=False)),
49+
('history_date', models.DateTimeField(db_index=True)),
50+
('history_change_reason', models.CharField(max_length=100, null=True)),
51+
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
52+
],
53+
options={
54+
'verbose_name': 'historical comic',
55+
'verbose_name_plural': 'historical comics',
56+
'ordering': ('-history_date', '-history_id'),
57+
'get_latest_by': ('history_date', 'history_id'),
58+
},
59+
bases=(simple_history.models.HistoricalChanges, models.Model),
60+
),
61+
migrations.RemoveConstraint(
62+
model_name='item',
63+
name='app_item_source_valid',
64+
),
65+
migrations.RemoveConstraint(
66+
model_name='item',
67+
name='app_item_media_type_valid',
68+
),
69+
migrations.AlterField(
70+
model_name='item',
71+
name='media_type',
72+
field=models.CharField(choices=[('tv', 'TV Show'), ('season', 'TV Season'), ('episode', 'Episode'), ('movie', 'Movie'), ('anime', 'Anime'), ('manga', 'Manga'), ('game', 'Game'), ('book', 'Book'), ('comic', 'Comic')], default='movie', max_length=10),
73+
),
74+
migrations.AlterField(
75+
model_name='item',
76+
name='source',
77+
field=models.CharField(choices=[('tmdb', 'The Movie Database'), ('mal', 'MyAnimeList'), ('mangaupdates', 'MangaUpdates'), ('igdb', 'Internet Game Database'), ('openlibrary', 'Open Library'), ('comicvine', 'Comic Vine'), ('manual', 'Manual')], max_length=20),
78+
),
79+
migrations.AddConstraint(
80+
model_name='item',
81+
constraint=models.CheckConstraint(condition=models.Q(('source__in', ['tmdb', 'mal', 'mangaupdates', 'igdb', 'openlibrary', 'comicvine', 'manual'])), name='app_item_source_valid'),
82+
),
83+
migrations.AddConstraint(
84+
model_name='item',
85+
constraint=models.CheckConstraint(condition=models.Q(('media_type__in', ['tv', 'season', 'episode', 'movie', 'anime', 'manga', 'game', 'book', 'comic'])), name='app_item_media_type_valid'),
86+
),
87+
migrations.AddField(
88+
model_name='comic',
89+
name='item',
90+
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.item'),
91+
),
92+
migrations.AddField(
93+
model_name='comic',
94+
name='user',
95+
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
96+
),
97+
migrations.AddField(
98+
model_name='historicalcomic',
99+
name='history_user',
100+
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
101+
),
102+
migrations.AddConstraint(
103+
model_name='comic',
104+
constraint=models.UniqueConstraint(fields=('item', 'user'), name='app_comic_unique_item_user'),
105+
),
106+
]

src/app/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class Sources(models.TextChoices):
4444
MANGAUPDATES = "mangaupdates", "MangaUpdates"
4545
IGDB = "igdb", "Internet Game Database"
4646
OPENLIBRARY = "openlibrary", "Open Library"
47+
COMICVINE = "comicvine", "Comic Vine"
4748
MANUAL = "manual", "Manual"
4849

4950

@@ -58,6 +59,7 @@ class MediaTypes(models.TextChoices):
5859
MANGA = "manga", "Manga"
5960
GAME = "game", "Game"
6061
BOOK = "book", "Book"
62+
COMIC = "comic", "Comic"
6163

6264

6365
class Colors(models.TextChoices):
@@ -1173,3 +1175,9 @@ class Book(Media):
11731175
"""Model for books."""
11741176

11751177
tracker = FieldTracker()
1178+
1179+
1180+
class Comic(Media):
1181+
"""Model for comics."""
1182+
1183+
tracker = FieldTracker()

src/app/providers/comicvine.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
from bs4 import BeautifulSoup
2+
from django.conf import settings
3+
from django.core.cache import cache
4+
5+
from app.providers import services
6+
7+
base_url = "https://comicvine.gamespot.com/api"
8+
headers = {
9+
"User-Agent": "Mozilla/5.0",
10+
}
11+
12+
13+
def search(query):
14+
"""Search for comics on Comic Vine."""
15+
cache_key = f"search_comicvine_{query}"
16+
data = cache.get(cache_key)
17+
18+
if data is None:
19+
params = {
20+
"api_key": settings.COMICVINE_API,
21+
"format": "json",
22+
"query": query,
23+
"resources": "volume",
24+
"field_list": "id,name,image",
25+
"limit": 20,
26+
}
27+
28+
response = services.api_request(
29+
"ComicVine",
30+
"GET",
31+
f"{base_url}/search/",
32+
params=params,
33+
headers=headers,
34+
)
35+
36+
data = [
37+
{
38+
"media_id": str(item["id"]),
39+
"source": "comicvine",
40+
"media_type": "comic",
41+
"title": item["name"],
42+
"image": get_image(item),
43+
}
44+
for item in response["results"]
45+
]
46+
47+
cache.set(cache_key, data)
48+
49+
return data
50+
51+
52+
def comic(media_id):
53+
"""Return the metadata for the selected comic volume from Comic Vine."""
54+
cache_key = f"comicvine_volume_{media_id}"
55+
data = cache.get(cache_key)
56+
57+
if data is None:
58+
params = {
59+
"api_key": settings.COMICVINE_API,
60+
"format": "json",
61+
"field_list": (
62+
"publisher,site_detail_url,name,last_issue,image,issues,"
63+
"description,concepts,start_year,count_of_issues,people,"
64+
),
65+
}
66+
67+
response = services.api_request(
68+
"ComicVine",
69+
"GET",
70+
f"{base_url}/volume/4050-{media_id}/",
71+
params=params,
72+
headers=headers,
73+
)
74+
75+
response = response.get("results", {})
76+
publisher_id = response["publisher"]["id"]
77+
recommendations = []
78+
if publisher_id:
79+
recommendations = get_similar_comics(publisher_id, media_id)
80+
81+
data = {
82+
"media_id": media_id,
83+
"source": "comicvine",
84+
"source_url": response["site_detail_url"],
85+
"media_type": "comic",
86+
"title": response["name"],
87+
"max_progress": get_last_issue_number(response),
88+
"image": get_image(response),
89+
"synopsis": get_synopsis(response),
90+
"genres": get_genres(response),
91+
"details": {
92+
"start_date": get_start_year(response),
93+
"publisher": get_publisher_name(response),
94+
"issues_count": get_issues_count(response),
95+
"last_issue_name": get_last_issue_name(response),
96+
"last_issue_number": get_last_issue_number(response),
97+
"people": get_people(response),
98+
},
99+
"related": {
100+
"from_the_same_publisher": recommendations,
101+
},
102+
}
103+
104+
cache.set(cache_key, data)
105+
106+
return data
107+
108+
109+
def get_readable_status(status):
110+
"""Convert API status to readable format."""
111+
status_map = {
112+
None: "Unknown",
113+
"": "Unknown",
114+
"Completed": "Completed",
115+
"Ongoing": "Ongoing",
116+
}
117+
return status_map.get(status, "Unknown")
118+
119+
120+
def get_image(response):
121+
"""Return the image URL."""
122+
if "image" in response:
123+
return response["image"]["medium_url"]
124+
return settings.IMG_NONE
125+
126+
127+
def get_synopsis(response):
128+
"""Return the synopsis."""
129+
if "description" not in response:
130+
return "No synopsis available"
131+
132+
soup = BeautifulSoup(response["description"], "html.parser")
133+
text = soup.get_text(separator=" ")
134+
return " ".join(text.split())
135+
136+
137+
def get_genres(response):
138+
"""Return the list of genres."""
139+
if "concepts" in response:
140+
return [concept["name"] for concept in response["concepts"]]
141+
return None
142+
143+
144+
def get_start_year(response):
145+
"""Return the start year of the comic volume."""
146+
return response.get("start_year")
147+
148+
149+
def get_publisher_name(response):
150+
"""Return the publisher name of the comic volume."""
151+
publisher = response.get("publisher")
152+
if publisher and isinstance(publisher, dict):
153+
return publisher.get("name")
154+
return None
155+
156+
157+
def get_issues_count(response):
158+
"""Return the count of issues in the comic volume."""
159+
return response.get("count_of_issues")
160+
161+
162+
def get_last_issue_name(response):
163+
"""Return the name of the last issue in the comic volume."""
164+
last_issue = response.get("last_issue")
165+
if last_issue and isinstance(last_issue, dict):
166+
return last_issue.get("name")
167+
return None
168+
169+
170+
def get_last_issue_number(response):
171+
"""Return the last issue number."""
172+
last_issue = response.get("last_issue")
173+
if last_issue and isinstance(last_issue, dict):
174+
return int(last_issue.get("issue_number"))
175+
return None
176+
177+
178+
def get_people(response):
179+
"""Return the people associated with the comic volume."""
180+
people = response.get("people", [])
181+
return [person["name"] for person in people[:5] if isinstance(person, dict)]
182+
183+
184+
def get_similar_comics(publisher_id, current_id, limit=10):
185+
"""Get similar comics from the same publisher."""
186+
cache_key = f"comicvine_similar_{publisher_id}_{current_id}"
187+
data = cache.get(cache_key)
188+
189+
if data is None:
190+
params = {
191+
"api_key": settings.COMICVINE_API,
192+
"format": "json",
193+
"field_list": "id,name,image,start_year,publisher",
194+
"filter": f"publisher:{publisher_id}",
195+
"limit": limit + 1, # Get one extra to account for current comic
196+
}
197+
198+
response = services.api_request(
199+
"ComicVine",
200+
"GET",
201+
f"{base_url}/volumes/",
202+
params=params,
203+
headers=headers,
204+
)
205+
206+
# Filter out the current comic and format the response
207+
data = [
208+
{
209+
"media_id": str(item["id"]),
210+
"source": "comicvine",
211+
"media_type": "comic",
212+
"title": item["name"],
213+
"image": get_image(item),
214+
}
215+
for item in response["results"]
216+
if str(item["id"]) != current_id
217+
][:limit]
218+
219+
cache.set(cache_key, data)
220+
221+
return data

0 commit comments

Comments
 (0)