add one-shot support and lots of additional series
174
data.py
|
@ -1,5 +1,5 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
|
||||
colors_c1 = {
|
||||
"Travis": "#7592a4",
|
||||
|
@ -79,7 +79,9 @@ assert set(single_speaker["Handbooker Helper"].keys()) == set(range(1, 44 + 1))
|
|||
@dataclass
|
||||
class SeriesData:
|
||||
name: str
|
||||
playlist_id: str
|
||||
slug: str
|
||||
playlist_id: Optional[str] = None
|
||||
videos: Optional[List[str]] = None
|
||||
single_speaker: bool = False
|
||||
initial_speaker: Optional[str] = None
|
||||
|
||||
|
@ -87,29 +89,197 @@ class SeriesData:
|
|||
series_data = [
|
||||
SeriesData(
|
||||
name="Campaign 1",
|
||||
slug="campaign1",
|
||||
playlist_id="PL1tiwbzkOjQz7D0l_eLJGAISVtcL7oRu_"
|
||||
),
|
||||
SeriesData(
|
||||
name="Campaign 2",
|
||||
slug="campaign2",
|
||||
playlist_id="PL1tiwbzkOjQxD0jjAE7PsWoaCrs0EkBH2"
|
||||
),
|
||||
SeriesData(
|
||||
name="Handbooker Helper",
|
||||
slug="HandbookerHelper",
|
||||
playlist_id="PL1tiwbzkOjQyr6-gqJ8r29j_rJkR49uDN",
|
||||
single_speaker=True
|
||||
),
|
||||
SeriesData(
|
||||
name="Mini Primetime",
|
||||
slug="MiniPrimetime",
|
||||
playlist_id="PL1tiwbzkOjQz9kKDaPRPrX2E7RPTaxEZd",
|
||||
initial_speaker="Will"
|
||||
),
|
||||
SeriesData(
|
||||
name="The Legend of The Legend of Vox Machina",
|
||||
slug="TheLegendofTheLegendofVoxMachina",
|
||||
playlist_id="PL1tiwbzkOjQwJdoNetaNJE1zZVOE7xi8u"
|
||||
),
|
||||
SeriesData(
|
||||
name="Crit Recap Animated",
|
||||
slug="CritRecapAnimated",
|
||||
playlist_id="PL1tiwbzkOjQy8yF8esjgVomDXKD-XmG20",
|
||||
initial_speaker="?"
|
||||
),
|
||||
SeriesData(
|
||||
name="Exandria Unlimited",
|
||||
slug="ExandriaUnlimited",
|
||||
playlist_id="PL1tiwbzkOjQzSnYHVT8X4pyMIbSX3i4gz"
|
||||
),
|
||||
SeriesData(
|
||||
name="Critter Hug",
|
||||
slug="CritterHug",
|
||||
playlist_id="PL1tiwbzkOjQw6CxZVgtRsY_0WqK5DsNE1",
|
||||
single_speaker=True # no names in subtitles
|
||||
),
|
||||
SeriesData(
|
||||
name="UnDeadwood",
|
||||
slug="UnDeadwood",
|
||||
# playlist_id="PL1tiwbzkOjQwuwLkGnqVdJnzQ-YNX2_qz"
|
||||
videos=["AEIGOY6WDoA", "JlAW2qeLsL0", "jSGw5L9xds0", "WHxuuQ-P2Cg"]
|
||||
),
|
||||
SeriesData(
|
||||
name="The Adventures of the Darrington Brigade",
|
||||
slug="darringtonBrigade",
|
||||
videos=["pVu_Ib1fpVI"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Dalen's Closet",
|
||||
slug="DalensCloset",
|
||||
videos=["0oclW3MXABA"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Call of Cthulhu: Shadow of the Crystal Palace",
|
||||
slug="CallOfCthulhu",
|
||||
videos=["0uhqZdJ8swQ"],
|
||||
single_speaker=True # no names in subtitles
|
||||
),
|
||||
SeriesData(
|
||||
name="The Search For Bob",
|
||||
slug="TheSearchForBob",
|
||||
videos=["AfEZF5G9HV4"]
|
||||
),
|
||||
# Tails of Equestria One-Shot
|
||||
SeriesData(
|
||||
name="Stephen Colbert's D&D Adventure with Matthew Mercer",
|
||||
slug="StephenColbertOneShot",
|
||||
videos=["3658C2y4LlA"],
|
||||
single_speaker=True # no names in subtitles
|
||||
),
|
||||
SeriesData(
|
||||
name="The Search For Grog",
|
||||
slug="TheSearchForGrog",
|
||||
videos=["hi5pEHs76TE"]
|
||||
),
|
||||
SeriesData(
|
||||
name="The Night Before Critmas",
|
||||
slug="TheNightBeforeCritmas",
|
||||
videos=["8zxeGydXY98"],
|
||||
single_speaker=True # no names in subtitles
|
||||
),
|
||||
SeriesData(
|
||||
name="Honey Heist",
|
||||
slug="HoneyHeist",
|
||||
videos=["9jbGshiuFs4", "MSNK4ThPHqc", "whbc64O0Yik"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Epic Level Battle Royale One-Shot",
|
||||
slug="EpicLevelBattleRoyaleOneShot",
|
||||
videos=["q3BGg0d8DvU"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Sam's One-Shot",
|
||||
slug="SamsOneShot",
|
||||
videos=["LfeAYN8f1AU"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Hearthstone One-Shot",
|
||||
slug="HearthstoneOneShot",
|
||||
videos=["qA4-q4gk_yY"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Grog's One-Shot",
|
||||
slug="GrogsOneShot",
|
||||
videos=["kLnvrocetq8"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Bar Room Blitz",
|
||||
slug="BarRoomBlitz",
|
||||
videos=["rnq3VBQu_kI"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Thursday By Night",
|
||||
slug="ThursdayByNight",
|
||||
videos=["rnq3VBQu_kI", "eXPu1wk-Ev4"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Shadow of War",
|
||||
slug="ShadowofWar",
|
||||
videos=["c9lC5_qjkFE", "Mk21j54rX-M"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Battle Royale One-Shot",
|
||||
slug="BattleRoyaleOneShot",
|
||||
videos=["tasz1xUVLhg"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Liam's Quest: Full Circle",
|
||||
slug="LiamsQuestFullCircle",
|
||||
videos=["LHita2t54xY"]
|
||||
),
|
||||
SeriesData(
|
||||
name="The Return of Liam!",
|
||||
slug="TheReturnofLiam",
|
||||
videos=["LgHm3Ct0Zh0"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Show Q&A and Battle Royale",
|
||||
slug="ShowQnAandBattleRoyale",
|
||||
videos=["4FI8qB-yh-w"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Liam's Quest!",
|
||||
slug="LiamsQuest",
|
||||
videos=["7Tdl6GhiSI8"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Deadlands One-Shot",
|
||||
slug="DeadlandsOneShot",
|
||||
videos=["q0hjGf2bK08"]
|
||||
),
|
||||
SeriesData(
|
||||
name="TO THE POOP! - The Goblins",
|
||||
slug="ToThePoop",
|
||||
videos=["u8MRyyFDX3c"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Critical Trolls",
|
||||
slug="CriticalTrolls",
|
||||
videos=["EjimabBvZgw"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Cinderbrush: A Monsterhearts Story",
|
||||
slug="Cinderbrush",
|
||||
videos=["51ykIVq9KcM"]
|
||||
),
|
||||
SeriesData(
|
||||
name="DOOM Eternal One-Shot",
|
||||
slug="DOOMEternalOneShot",
|
||||
videos=["CX8I4M7MPo4"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Diablo One Shot",
|
||||
slug="DiabloOneShot",
|
||||
videos=["yODMT1m85FQ"]
|
||||
),
|
||||
SeriesData(
|
||||
name="The Elder Scrolls Online: Blackwood",
|
||||
slug="TheElderScrollsOnline",
|
||||
videos=["E-YCzpYDIyA"]
|
||||
),
|
||||
SeriesData(
|
||||
name="Vox Machina vs. Mighty Nein",
|
||||
slug="VoxMachinaVsMightyNein",
|
||||
videos=["LpBIQhWAhuM"]
|
||||
)
|
||||
|
||||
]
|
||||
|
|
56
fetch.py
|
@ -1,9 +1,12 @@
|
|||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from shutil import move
|
||||
from subprocess import run
|
||||
|
||||
import requests
|
||||
import youtube_dl
|
||||
from peewee import DoesNotExist
|
||||
|
||||
|
@ -11,8 +14,10 @@ from data import series_data
|
|||
from models import Episode, Series, Line, Phrase
|
||||
from utils import srtdir, pretty_title, title_to_episodenumber
|
||||
|
||||
static_path = Path("static")
|
||||
|
||||
def main() -> None:
|
||||
|
||||
def main(args) -> None:
|
||||
os.nice(15)
|
||||
for series in series_data:
|
||||
name = series.name
|
||||
|
@ -26,42 +31,53 @@ def main() -> None:
|
|||
|
||||
s.is_campaign = is_campaign
|
||||
s.single_speaker = series.single_speaker
|
||||
s.slug = series.slug
|
||||
s.save()
|
||||
ydl_opts = {
|
||||
'extract_flat': True
|
||||
}
|
||||
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
|
||||
playlist = ydl.extract_info("https://www.youtube.com/playlist?list=" + playlist_id, download=False)
|
||||
videos = playlist["entries"]
|
||||
|
||||
print(v["url"] for v in videos)
|
||||
if series.playlist_id:
|
||||
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
|
||||
playlist = ydl.extract_info("https://www.youtube.com/playlist?list=" + playlist_id, download=False)
|
||||
videos = playlist["entries"]
|
||||
|
||||
urls = [v["url"] for v in videos]
|
||||
else:
|
||||
urls = series.videos
|
||||
ydl_opts_download = {
|
||||
"writesubtitles": True,
|
||||
"subtitleslangs": ["en", "en-US"],
|
||||
"skip_download": True,
|
||||
}
|
||||
|
||||
for nr, video in enumerate(videos, 1):
|
||||
for nr, url in enumerate(urls, 1):
|
||||
try:
|
||||
e = Episode.select().where((Episode.series == s) & (Episode.video_number == nr)).get()
|
||||
# if (e.series.id == 1) or (e.series.id == 2 and e.video_number < 90):
|
||||
# continue
|
||||
# if e.downloaded:
|
||||
# continue
|
||||
e = Episode.select().where((Episode.youtube_id == url)).get()
|
||||
if args.skip_existing and e.downloaded:
|
||||
continue
|
||||
except DoesNotExist:
|
||||
e = Episode()
|
||||
e.series = s
|
||||
e.video_number = nr
|
||||
e.title = video["title"]
|
||||
e.pretty_title = pretty_title(video["title"])
|
||||
if s.is_campaign:
|
||||
if e.series.id == 1 and ("Search For Grog" in e.title or "Search For Bob" in e.title):
|
||||
e.youtube_id = url
|
||||
video_info = ydl.extract_info(f'https://www.youtube.com/watch?v={e.youtube_id}', download=False)
|
||||
if nr == 1:
|
||||
file = static_path / f"{s.slug}.webp"
|
||||
if file.exists():
|
||||
continue
|
||||
r = requests.get(f"https://i.ytimg.com/vi_webp/{e.youtube_id}/maxresdefault.webp")
|
||||
r.raise_for_status()
|
||||
with file.open("wb")as f:
|
||||
f.write(r.content)
|
||||
e.upload_date = datetime.strptime(video_info["upload_date"], "%Y%m%d")
|
||||
e.title = video_info["title"]
|
||||
e.pretty_title = pretty_title(video_info["title"])
|
||||
if s.is_campaign or "Exandria" in e.title:
|
||||
if e.series.id == 1 and ("One-Shot" in e.title or "Search For Bob" in e.title):
|
||||
continue
|
||||
e.episode_number = title_to_episodenumber(e.title, e.video_number)
|
||||
else:
|
||||
e.episode_number = e.video_number
|
||||
e.youtube_id = video["url"]
|
||||
e.save()
|
||||
print(e.series.id, e.episode_number, e.pretty_title)
|
||||
|
||||
|
@ -100,4 +116,8 @@ def main() -> None:
|
|||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
parser = argparse.ArgumentParser(description="fetch episode data from YouTube")
|
||||
parser.add_argument("--skip-existing", dest="skip_existing", action="store_true",
|
||||
help="don't check for update on existing videos")
|
||||
args = parser.parse_args()
|
||||
main(args)
|
||||
|
|
28
models.py
|
@ -1,6 +1,7 @@
|
|||
from datetime import datetime
|
||||
|
||||
from peewee import PostgresqlDatabase, Model, IntegerField, CharField, BooleanField, ForeignKeyField, DateTimeField
|
||||
from peewee import PostgresqlDatabase, Model, IntegerField, CharField, BooleanField, ForeignKeyField, DateTimeField, \
|
||||
DateField
|
||||
from playhouse.postgres_ext import TSVectorField
|
||||
|
||||
from config import dbauth
|
||||
|
@ -16,12 +17,16 @@ class BaseModel(Model):
|
|||
|
||||
class Series(BaseModel):
|
||||
title = CharField(max_length=100)
|
||||
slug = CharField(max_length=100)
|
||||
is_campaign = BooleanField()
|
||||
single_speaker = BooleanField()
|
||||
|
||||
def __str__(self):
|
||||
return f"<Series: {self.title}>"
|
||||
|
||||
|
||||
class Episode(BaseModel):
|
||||
series = ForeignKeyField(Series, backref="episodes")
|
||||
series = ForeignKeyField(Series, backref="episodes", on_delete="CASCADE")
|
||||
episode_number = IntegerField()
|
||||
video_number = IntegerField()
|
||||
youtube_id = CharField(max_length=11)
|
||||
|
@ -31,6 +36,7 @@ class Episode(BaseModel):
|
|||
text_imported = BooleanField(default=False)
|
||||
phrases_imported = BooleanField(default=False)
|
||||
subtitle_hash = CharField(max_length=64, null=True)
|
||||
upload_date = DateField()
|
||||
last_updated = DateTimeField(default=datetime.now)
|
||||
|
||||
class Meta:
|
||||
|
@ -40,6 +46,9 @@ class Episode(BaseModel):
|
|||
def name(self) -> str:
|
||||
return f"C{self.season}E{self.episode_number:03d}"
|
||||
|
||||
def __str__(self):
|
||||
return f"<Episode: {self.title}>"
|
||||
|
||||
|
||||
class Person(BaseModel):
|
||||
name = CharField()
|
||||
|
@ -49,26 +58,35 @@ class Person(BaseModel):
|
|||
class Meta:
|
||||
indexes = ((("name", "series"), True),)
|
||||
|
||||
def __str__(self):
|
||||
return f"<Person: {self.name}>"
|
||||
|
||||
|
||||
class Line(BaseModel):
|
||||
text = CharField()
|
||||
search_text = TSVectorField()
|
||||
person = ForeignKeyField(Person, backref="lines", null=True)
|
||||
person = ForeignKeyField(Person, backref="lines", null=True, on_delete="CASCADE")
|
||||
isnote = BooleanField(default=False)
|
||||
ismeta = BooleanField(default=False)
|
||||
starttime = IntegerField()
|
||||
endtime = IntegerField()
|
||||
episode = ForeignKeyField(Episode, backref="lines")
|
||||
episode = ForeignKeyField(Episode, backref="lines", on_delete="CASCADE")
|
||||
order = IntegerField()
|
||||
|
||||
class Meta:
|
||||
indexes = ((("episode", "order"), True),)
|
||||
|
||||
def __str__(self):
|
||||
return f"<Line: {self.pk}>"
|
||||
|
||||
|
||||
class Phrase(BaseModel):
|
||||
text = CharField()
|
||||
count = IntegerField()
|
||||
episode = ForeignKeyField(Episode, backref="phrase")
|
||||
episode = ForeignKeyField(Episode, backref="phrase", on_delete="CASCADE")
|
||||
|
||||
class Meta:
|
||||
indexes = ((("text", "episode"), True),)
|
||||
|
||||
def __str__(self):
|
||||
return f"<Line: {self.text} ({self.pk})>"
|
||||
|
|
|
@ -74,7 +74,7 @@ for episode in Episode.select().where((Episode.phrases_imported == False) & (Epi
|
|||
|
||||
num_per_chunk = 100
|
||||
chunks = chunked(phrases, num_per_chunk)
|
||||
with alive_bar(len(phrases) // num_per_chunk + 1) as bar:
|
||||
with alive_bar(len(phrases) // num_per_chunk + 1, title="saving") as bar:
|
||||
for chunk in chunks:
|
||||
bar()
|
||||
Phrase.bulk_create(chunk)
|
||||
|
|
20
server.py
|
@ -21,15 +21,15 @@ def add_cors(response: Response) -> Response:
|
|||
return response
|
||||
|
||||
|
||||
def suggest(query: str, until: int, series: int, limit: int = 10) -> ModelSelect:
|
||||
return Phrase.select(Phrase.text, Alias(fn.SUM(Phrase.count), "total_count")).join(Episode).where(
|
||||
(Episode.series == series) &
|
||||
def suggest(query: str, until: int, series: str, limit: int = 10) -> ModelSelect:
|
||||
return Phrase.select(Phrase.text, Alias(fn.SUM(Phrase.count), "total_count")).join(Episode).join(Series).where(
|
||||
(Episode.series.slug == series) &
|
||||
(Episode.episode_number <= until) &
|
||||
(Phrase.text.contains(query))
|
||||
).group_by(Phrase.text).order_by(SQL("total_count DESC")).limit(limit)
|
||||
|
||||
|
||||
def search(query: str, until: int, series: int, limit: int = 50) -> ModelSelect:
|
||||
def search(query: str, until: int, series: str, limit: int = 50) -> ModelSelect:
|
||||
a = Alias(fn.ts_rank_cd(Line.search_text, fn.websearch_to_tsquery('english', query), 1 + 4), "rank")
|
||||
|
||||
return Line.select(Line, Person, Episode, Series, a).where(
|
||||
|
@ -37,7 +37,7 @@ def search(query: str, until: int, series: int, limit: int = 50) -> ModelSelect:
|
|||
&
|
||||
(Episode.episode_number <= until)
|
||||
&
|
||||
(Episode.series == series)
|
||||
(Episode.series.slug == series)
|
||||
).order_by(SQL("rank DESC")) \
|
||||
.join(Person).switch(Line) \
|
||||
.join(Episode).join(Series) \
|
||||
|
@ -127,7 +127,13 @@ def series():
|
|||
series_list = []
|
||||
|
||||
for series in Series.select():
|
||||
series_list.append({"title": series.title, "id": series.id})
|
||||
last_episode: Episode = Episode.select().where(Episode.series == series).order_by(
|
||||
Episode.upload_date.desc()).limit(
|
||||
1).get()
|
||||
series_data = model_to_dict(series)
|
||||
series_data["last_upload"] = last_episode.upload_date.strftime("%Y-%m-%d")
|
||||
series_data["length"] = Episode.select().where(Episode.series == series).count()
|
||||
series_list.append(series_data)
|
||||
return jsonify({
|
||||
"series": series_list
|
||||
})
|
||||
|
@ -144,6 +150,8 @@ def api_episodes():
|
|||
series_data = []
|
||||
for episode in episodes:
|
||||
entry = model_to_dict(episode, exclude=[Episode.series, Episode.title])
|
||||
if entry["upload_date"]:
|
||||
entry["upload_date"] = entry["upload_date"].strftime("%Y-%m-%d")
|
||||
series_data.append(entry)
|
||||
data.append({
|
||||
"meta": model_to_dict(series),
|
||||
|
|
BIN
static/BarRoomBlitz.webp
Normal file
After Width: | Height: | Size: 284 KiB |
BIN
static/BattleRoyaleOneShot.webp
Normal file
After Width: | Height: | Size: 245 KiB |
BIN
static/CallOfCthulhu.webp
Normal file
After Width: | Height: | Size: 104 KiB |
BIN
static/Cinderbrush.webp
Normal file
After Width: | Height: | Size: 105 KiB |
BIN
static/CritRecapAnimated.webp
Normal file
After Width: | Height: | Size: 194 KiB |
BIN
static/CriticalTrolls.webp
Normal file
After Width: | Height: | Size: 53 KiB |
BIN
static/CritterHug.webp
Normal file
After Width: | Height: | Size: 117 KiB |
BIN
static/DOOMEternalOneShot.webp
Normal file
After Width: | Height: | Size: 88 KiB |
BIN
static/DalensCloset.webp
Normal file
After Width: | Height: | Size: 95 KiB |
BIN
static/DeadlandsOneShot.webp
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
static/DiabloOneShot.webp
Normal file
After Width: | Height: | Size: 111 KiB |
BIN
static/EpicLevelBattleRoyaleOneShot.webp
Normal file
After Width: | Height: | Size: 228 KiB |
BIN
static/ExandriaUnlimited.webp
Normal file
After Width: | Height: | Size: 90 KiB |
BIN
static/GrogsOneShot.webp
Normal file
After Width: | Height: | Size: 131 KiB |
BIN
static/HandbookerHelper.webp
Normal file
After Width: | Height: | Size: 101 KiB |
BIN
static/HearthstoneOneShot.webp
Normal file
After Width: | Height: | Size: 200 KiB |
BIN
static/HoneyHeist.webp
Normal file
After Width: | Height: | Size: 133 KiB |
BIN
static/LiamsQuest.webp
Normal file
After Width: | Height: | Size: 133 KiB |
BIN
static/LiamsQuestFullCircle.webp
Normal file
After Width: | Height: | Size: 225 KiB |
BIN
static/MiniPrimetime.webp
Normal file
After Width: | Height: | Size: 50 KiB |
BIN
static/SamsOneShot.webp
Normal file
After Width: | Height: | Size: 159 KiB |
BIN
static/ShadowofWar.webp
Normal file
After Width: | Height: | Size: 102 KiB |
BIN
static/ShowQnAandBattleRoyale.webp
Normal file
After Width: | Height: | Size: 253 KiB |
BIN
static/StephenColbertOneShot.webp
Normal file
After Width: | Height: | Size: 136 KiB |
BIN
static/TheElderScrollsOnline.webp
Normal file
After Width: | Height: | Size: 127 KiB |
BIN
static/TheLegendofTheLegendofVoxMachina.webp
Normal file
After Width: | Height: | Size: 76 KiB |
BIN
static/TheNightBeforeCritmas.webp
Normal file
After Width: | Height: | Size: 135 KiB |
BIN
static/TheReturnofLiam.webp
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
static/TheSearchForBob.webp
Normal file
After Width: | Height: | Size: 300 KiB |
BIN
static/TheSearchForGrog.webp
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
static/ThursdayByNight.webp
Normal file
After Width: | Height: | Size: 160 KiB |
BIN
static/ToThePoop.webp
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
static/UnDeadwood.webp
Normal file
After Width: | Height: | Size: 69 KiB |
BIN
static/VoxMachinaVsMightyNein.webp
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
static/campaign1.webp
Normal file
After Width: | Height: | Size: 176 KiB |
BIN
static/campaign2.webp
Normal file
After Width: | Height: | Size: 74 KiB |
BIN
static/darringtonBrigade.webp
Normal file
After Width: | Height: | Size: 191 KiB |
|
@ -1,14 +1,17 @@
|
|||
-- number of subtitle lines per person
|
||||
select name, count(name) as count, sum(length(text)) as chars
|
||||
from line
|
||||
join person p on line.person_id = p.id
|
||||
group by name
|
||||
order by chars desc;
|
||||
|
||||
-- most common noun chunks
|
||||
select text, sum(count) as count
|
||||
from phrase
|
||||
group by text
|
||||
order by count desc;
|
||||
|
||||
-- longest noun chunks
|
||||
select text, char_length(phrase.text) as len
|
||||
from phrase
|
||||
order by len desc;
|
||||
|
@ -72,4 +75,4 @@ FROM "line" AS "t1"
|
|||
WHERE ((("t1"."search_text" @@ websearch_to_tsquery('english', 'house')) AND ("t3"."episode_number" <= 1000)) AND
|
||||
("t3"."season" = 1))
|
||||
ORDER BY rank DESC
|
||||
LIMIT 20
|
||||
LIMIT 20;
|
||||
|
|
12
typo.py
|
@ -7,7 +7,7 @@ typos = {
|
|||
"Sam": {"San", "Nott", "Sma", "Sasm", "Sm", "Ssam"},
|
||||
"Travis": {"Tarvis", "Travs", "Travia", "Traivs", "Tavis", "Trvis"},
|
||||
"Taliesin": {"Taiesin", "Talisin", "Talisen", "Taleisn", "Talisein", "Talieisin", "Talesin", "Talisan", "Taleisin",
|
||||
"Talieisn", "Talisien", "Tailesin", "Tlaiesin"},
|
||||
"Talieisn", "Talisien", "Tailesin", "Tlaiesin", "Tlaiesin"},
|
||||
"Marisha": {"Beau", "Mariasha", "Maisha", "Marisa", "Marish", "Marihsa", "Marsha", "Marsisha", "Marishaa",
|
||||
"Marihsha", "\\Marisha", "Marisah", "Marissa", "Marirsha", "Marisaha", "Mairsha", "Marshia", "Marsiha",
|
||||
"Marishia", "Marsiah", "Matisha", "Mraisha", "Amrisha", "<Arisha"},
|
||||
|
@ -16,10 +16,16 @@ typos = {
|
|||
"Ashley": {"Ashly", "Ashely", "Ashey", "Aslhey", "Ahsley"},
|
||||
"All": {"Everyone", "Everybody"},
|
||||
"Mark": {"Marik"},
|
||||
"Brian": {"Brain"},
|
||||
"Brian": {"Brain", "\"Brian"},
|
||||
"Joe": {"Jroe"},
|
||||
"Man Off-Camera": {"Man Off Camera"},
|
||||
"Off-Screen": {"Offscreen"}
|
||||
"Off-Screen": {"Offscreen"},
|
||||
"Anjali": {"Anajli"},
|
||||
"H. Michael": {"H Michael", "H.Michael"},
|
||||
"Allura": {"Alura"},
|
||||
"Krystina": {"Krystin"},
|
||||
"Michelle":{"Michlle"},
|
||||
"Alicia":{"Alica"}
|
||||
}
|
||||
replacements = {}
|
||||
for correct, typoset in typos.items():
|
||||
|
|
7
utils.py
|
@ -17,7 +17,10 @@ def milliseconds_to_td(ms: int) -> timedelta:
|
|||
|
||||
|
||||
def episode_speaker(series_title: str, episode: int) -> Optional[str]:
|
||||
series = single_speaker[series_title]
|
||||
try:
|
||||
series = single_speaker[series_title]
|
||||
except KeyError:
|
||||
return "?"
|
||||
if episode in series:
|
||||
return series[episode]
|
||||
return None
|
||||
|
@ -41,6 +44,8 @@ def title_to_episodenumber(title: str, video_number: int) -> int:
|
|||
except ValueError:
|
||||
if title == "Campaign 1": # one-shots at the end of campaign 1
|
||||
return video_number - 3
|
||||
elif "Exandria" in title:
|
||||
return 1
|
||||
else:
|
||||
raise
|
||||
|
||||
|
|
29178
web/package-lock.json
generated
Normal file
|
@ -22,6 +22,8 @@
|
|||
transform: translate(-50%, -50%);
|
||||
//border: 2px solid $border-color;
|
||||
z-index: 100;
|
||||
max-height: 95vh;
|
||||
overflow: auto;
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
width: 80%;
|
||||
|
@ -82,3 +84,33 @@
|
|||
{
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.seriesSelector {
|
||||
width: 95%;
|
||||
|
||||
.seriesList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.series {
|
||||
width: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flip-list-move {
|
||||
transition: transform 1s;
|
||||
}
|
||||
.flip-list-enter, .flip-list-leave-to
|
||||
/* .list-complete-leave-active below version 2.1.8 */ {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
.flip-list-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
|
61
web/src/components/SeriesSelector.vue
Normal file
|
@ -0,0 +1,61 @@
|
|||
<template>
|
||||
<div class="seriesSelector popup">
|
||||
<h1>Select Series</h1>
|
||||
<input type="checkbox" id="onlyCampaigns" v-model="onlyCampaigns">
|
||||
<label for="onlyCampaigns">show only campaigns</label>
|
||||
<input type="checkbox" id="showOneShots" v-model="showOneShots">
|
||||
<label for="showOneShots">show One-Shots</label>
|
||||
<input type="search" id="search" v-model="search">
|
||||
<label for="search">search</label>
|
||||
|
||||
<transition-group name="flip-list" class="seriesList" tag="div">
|
||||
<div class="series" v-for="series in sortedSeries" @click="selectSeries(series)" :key="series.id">
|
||||
<span>{{ series.title }}</span>
|
||||
<img :src="'http://127.0.0.1:5000/static/'+series.slug+'.webp'">
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, {PropType} from "vue";
|
||||
import {Series, SeriesData, ServerData} from "@/interfaces";
|
||||
|
||||
export default Vue.extend({
|
||||
name: "SeriesSelector",
|
||||
props: {
|
||||
serverData: Object as PropType<ServerData>
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
onlyCampaigns: false,
|
||||
showOneShots: true,
|
||||
search: ""
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
sortedSeries(): SeriesData[] {
|
||||
console.log(this.search);
|
||||
return this.serverData.series.filter((series) => {
|
||||
console.log(series.title.includes(this.search))
|
||||
if (!series.is_campaign && this.onlyCampaigns) {
|
||||
return false;
|
||||
}
|
||||
if (series.length === 1 && !this.showOneShots) {
|
||||
return false;
|
||||
}
|
||||
return this.search === "" || series.title.toLowerCase().includes(this.search.toLowerCase());
|
||||
|
||||
}).sort((a, b) => {
|
||||
return b.last_upload.localeCompare(a.last_upload);
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectSeries(series: SeriesData): void {
|
||||
// @ts-ignore
|
||||
this.$parent.selectSeries(series);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
|
@ -9,6 +9,12 @@ export interface Series {
|
|||
"is_campaign": boolean;
|
||||
"single_speaker": boolean;
|
||||
"title": string;
|
||||
"slug": string;
|
||||
}
|
||||
|
||||
export interface SeriesData extends Series {
|
||||
"last_upload": string;
|
||||
"length": number;
|
||||
}
|
||||
|
||||
export interface Episode {
|
||||
|
@ -44,19 +50,15 @@ export interface ServerMessage {
|
|||
message: string;
|
||||
}
|
||||
|
||||
export interface SeriesNames {
|
||||
"id": number;
|
||||
"title": string;
|
||||
}
|
||||
|
||||
export interface ServerData {
|
||||
"series": SeriesNames[];
|
||||
"series": SeriesData[];
|
||||
}
|
||||
|
||||
export interface EpisodeDetailed extends Episode {
|
||||
"downloaded": boolean;
|
||||
"text_imported": boolean;
|
||||
"phrases_imported": boolean;
|
||||
"upload_date": string;
|
||||
}
|
||||
|
||||
export interface SeriesData {
|
||||
|
|
|
@ -11,7 +11,7 @@ export default new Router({
|
|||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
redirect: "/search/2/10/",
|
||||
redirect: "/search/campaign2/10/",
|
||||
},
|
||||
{
|
||||
path: "/episodes",
|
||||
|
@ -20,11 +20,11 @@ export default new Router({
|
|||
},
|
||||
{
|
||||
path: "/:something/",
|
||||
redirect: "/search/2/10/",
|
||||
redirect: "/search/campaign2/10/",
|
||||
},
|
||||
{
|
||||
path: "/:something/:something/",
|
||||
redirect: "/search/2/10/",
|
||||
path: "/:something/:somethingElse/",
|
||||
redirect: "/search/campaign2/10/",
|
||||
},
|
||||
{
|
||||
path: "/search/:series/:episode/:keyword?",
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
<th>Title</th>
|
||||
<th>Episode</th>
|
||||
<th>Video</th>
|
||||
<th>Upload Date</th>
|
||||
<th>Subtitles available and fetched</th>
|
||||
<th>Subtitles imported in search</th>
|
||||
<th>Phrases imported for search suggestions</th>
|
||||
|
@ -27,6 +28,7 @@
|
|||
</td>
|
||||
<td>{{ episode.episode_number }}</td>
|
||||
<td>{{ episode.video_number }}</td>
|
||||
<td>{{episode.upload_date}}</td>
|
||||
<td class="text-center">
|
||||
<CheckMark :status="episode.downloaded"></CheckMark>
|
||||
</td>
|
||||
|
|
|
@ -6,8 +6,9 @@
|
|||
<span>Find your favourite Critical Role quote!</span>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div id="page-mask" v-if="showIntro || showYtOptIn"></div>
|
||||
<div id="page-mask" v-if="showIntro || showYtOptIn|| showSeriesSelector"></div>
|
||||
</transition>
|
||||
<SeriesSelector v-if="showSeriesSelector" :serverData="serverData"></SeriesSelector>
|
||||
<div v-if="showIntro" class="showIntro popup">
|
||||
<div class="title"><h1>Critical Role Search</h1></div>
|
||||
<div>
|
||||
|
@ -76,11 +77,15 @@
|
|||
class="form-control" type="number" v-model="episode"
|
||||
min="1" max="300">
|
||||
<span>in</span>
|
||||
<select title="campaign selection" class="custom-select" v-model="series">
|
||||
<option v-for="series in serverData.series" v-bind:value="series.id">
|
||||
{{ series.title }}
|
||||
</option>
|
||||
</select>
|
||||
<!-- <select title="campaign selection" class="custom-select" v-model="series">-->
|
||||
<!-- <option v-for="series in serverData.series" v-bind:value="series.slug">-->
|
||||
<!-- {{ series.title }}-->
|
||||
<!-- </option>-->
|
||||
<!-- </select>-->
|
||||
<button class="btn btn-outline-primary" @click="showSeriesSelector=true">
|
||||
{{ seriesTitle }}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<b-alert v-if="error" show :variant="error.status">{{ error.message }}</b-alert>
|
||||
<div class="entry" v-for="result in searchResult">
|
||||
|
@ -120,13 +125,14 @@ import Vue from "vue";
|
|||
// @ts-ignore
|
||||
import Autocomplete from "@trevoreyre/autocomplete-vue";
|
||||
// import "@trevoreyre/autocomplete-vue/dist/style.css";
|
||||
import {Line, Result, ServerData, ServerMessage} from "@/interfaces";
|
||||
import {Line, Result, Series, ServerData, ServerMessage} from "@/interfaces";
|
||||
import {BAlert, BIcon, BIconPlayFill} from "bootstrap-vue";
|
||||
// @ts-ignore
|
||||
import VueYoutube from "vue-youtube";
|
||||
import debounce from "lodash-es/debounce";
|
||||
|
||||
import {baseURL} from "@/utils";
|
||||
import SeriesSelector from "@/components/SeriesSelector.vue";
|
||||
|
||||
Vue.use(VueYoutube);
|
||||
|
||||
|
@ -134,6 +140,7 @@ Vue.use(VueYoutube);
|
|||
export default Vue.extend({
|
||||
name: "home",
|
||||
components: {
|
||||
SeriesSelector,
|
||||
Autocomplete,
|
||||
BAlert,
|
||||
BIcon,
|
||||
|
@ -141,7 +148,7 @@ export default Vue.extend({
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
serverData: {"series": []} as ServerData,
|
||||
serverData: {series: []} as ServerData,
|
||||
searchResult: [] as Result[],
|
||||
keyword: this.$route.params.keyword,
|
||||
series: this.$route.params.series,
|
||||
|
@ -153,7 +160,8 @@ export default Vue.extend({
|
|||
showYT: false,
|
||||
ytResult: undefined as Result | undefined,
|
||||
ytWidth: 640,
|
||||
showIntro: true
|
||||
showIntro: true,
|
||||
showSeriesSelector: false
|
||||
};
|
||||
},
|
||||
mounted(): void {
|
||||
|
@ -164,7 +172,7 @@ export default Vue.extend({
|
|||
this.ytOptIn = localStorage.ytOptIn;
|
||||
}
|
||||
if (this.series == null) {
|
||||
this.series = "2";
|
||||
this.series = "campaign2";
|
||||
}
|
||||
if (this.episode == null) {
|
||||
this.episode = "10";
|
||||
|
@ -302,6 +310,10 @@ export default Vue.extend({
|
|||
this.ytVideoID = undefined;
|
||||
this.ytResult = undefined;
|
||||
},
|
||||
selectSeries(series: Series): void {
|
||||
this.series = series.slug;
|
||||
this.showSeriesSelector = false;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
ytLink(): string {
|
||||
|
@ -314,6 +326,22 @@ export default Vue.extend({
|
|||
const min = Math.floor(starttime / 60);
|
||||
const sec = Math.floor(starttime % 60);
|
||||
return `https://www.youtube.com/watch?v=${id}&t=${min}m${sec}s`;
|
||||
},
|
||||
seriesFromSlug(): Series | undefined {
|
||||
if (!this.series) {
|
||||
return undefined;
|
||||
}
|
||||
return this.serverData.series.find((series) => {
|
||||
return series.slug === this.series;
|
||||
});
|
||||
},
|
||||
seriesTitle(): string {
|
||||
const series = this.seriesFromSlug;
|
||||
if (series) {
|
||||
return series.title;
|
||||
} else {
|
||||
return this.series;
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|