video 'capture everything': cast & crew (people + credits) + cast row UI
Last capture piece, schema v3 (new people + credits tables; CREATE IF NOT EXISTS migrates existing DBs on restart, no wipe): - people deduped by tmdb_id; credits link to exactly one movie OR show (separate nullable FKs + CHECK, no polymorphic id) with department/job/character/order. - TMDB client appends credits to the detail call (free) and parses cast (name, character, photo, billing order) + headline crew (directors/writers/creators). - enrichment_apply backfills cast/crew gap-only (never clobbers); show/movie detail return cast + crew. Populates on view via the existing lazy refresh-art. - Cast & Crew section on the detail page: grouped crew line + a horizontal cast row with circular TMDB headshots, names, characters (accent hover). Seam tests: TMDB credit parse (+ job filtering, created_by), backfill + people dedup across titles, gap-only no-clobber, payload shape.
This commit is contained in:
parent
8002f93220
commit
b9b3b9eed3
9 changed files with 226 additions and 5 deletions
|
|
@ -76,7 +76,8 @@ class TMDBClient:
|
|||
try:
|
||||
detail_path = "/movie/" if kind == "movie" else "/tv/"
|
||||
dr = requests.get(self.BASE + detail_path + str(tmdb_id),
|
||||
params={"api_key": self.api_key, "append_to_response": "external_ids"},
|
||||
params={"api_key": self.api_key,
|
||||
"append_to_response": "external_ids,credits"},
|
||||
timeout=15).json() or {}
|
||||
meta["overview"] = dr.get("overview") or meta.get("overview")
|
||||
if dr.get("backdrop_path"):
|
||||
|
|
@ -111,10 +112,32 @@ class TMDBClient:
|
|||
seasons.append({"season_number": sn, "poster_url": self.IMG + pp})
|
||||
if seasons:
|
||||
meta["seasons"] = seasons
|
||||
self._add_credits(meta, dr.get("credits") or {}, dr.get("created_by") or [])
|
||||
except Exception:
|
||||
logger.exception("TMDB details fetch failed for %s", title or tmdb_id)
|
||||
return {"id": tmdb_id, "metadata": {k: v for k, v in meta.items() if v}}
|
||||
|
||||
PROFILE = "https://image.tmdb.org/t/p/w185"
|
||||
|
||||
def _person(self, c, job=None, character=None):
|
||||
return {"name": c["name"], "tmdb_id": c.get("id"), "job": job, "character": character,
|
||||
"photo_url": (self.PROFILE + c["profile_path"]) if c.get("profile_path") else None}
|
||||
|
||||
def _add_credits(self, meta, credits, created_by):
|
||||
"""Parse TMDB cast/crew into meta['cast'] / meta['crew']."""
|
||||
cast = [self._person(c, character=c.get("character"))
|
||||
for c in (credits.get("cast") or [])[:20] if c.get("name")]
|
||||
if cast:
|
||||
meta["cast"] = cast
|
||||
# Crew: headline jobs only (directors / writers); plus TV creators, which
|
||||
# live in the top-level created_by, not the crew list.
|
||||
wanted = {"Director", "Writer", "Screenplay"}
|
||||
crew = [self._person(c, job=c.get("job")) for c in (credits.get("crew") or [])
|
||||
if c.get("name") and c.get("job") in wanted]
|
||||
crew += [self._person(c, job="Creator") for c in created_by if c.get("name")]
|
||||
if crew:
|
||||
meta["crew"] = crew
|
||||
|
||||
def season_episodes(self, tv_id, season_number):
|
||||
"""Episode-level data for one season (still/overview/rating) — the show
|
||||
worker cascades over a show's seasons to backfill episodes the media
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ logger = get_logger("video_database")
|
|||
|
||||
# Bump when video_schema.sql changes in a way worth recording. Stored in
|
||||
# PRAGMA user_version as a backstop indicator (nothing gates on it yet).
|
||||
SCHEMA_VERSION = 2
|
||||
SCHEMA_VERSION = 3
|
||||
|
||||
_DEFAULT_DB_PATH = "database/video_library.db"
|
||||
_SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql"
|
||||
|
|
@ -256,6 +256,14 @@ class VideoDatabase:
|
|||
has = conn.execute(f"SELECT 1 FROM {lt} WHERE {oc}=? LIMIT 1", (item_id,)).fetchone()
|
||||
if not has:
|
||||
self._set_genres(conn, lt, oc, item_id, genres)
|
||||
# Cast/crew backfill — only when the item has none yet (gap-fill).
|
||||
cast = (metadata or {}).get("cast")
|
||||
crew = (metadata or {}).get("crew")
|
||||
if matched and (cast or crew) and tbl in ("movies", "shows"):
|
||||
oc = "movie_id" if tbl == "movies" else "show_id"
|
||||
has = conn.execute(f"SELECT 1 FROM credits WHERE {oc}=? LIMIT 1", (item_id,)).fetchone()
|
||||
if not has:
|
||||
self._set_credits(conn, oc, item_id, cast or [], crew or [])
|
||||
# Per-season poster backfill (TMDB) — fills only seasons the server
|
||||
# left without art.
|
||||
seasons_meta = (metadata or {}).get("seasons")
|
||||
|
|
@ -566,6 +574,53 @@ class VideoDatabase:
|
|||
conn.rollback() # legacy UNIQUE on an id — keep the row, drop the id
|
||||
run(False)
|
||||
|
||||
@staticmethod
|
||||
def _set_credits(conn, owner_col: str, owner_id: int, cast, crew) -> None:
|
||||
"""Replace the cast+crew for one owner (deduped people in the shared
|
||||
people table). owner_col is internal ('movie_id'|'show_id')."""
|
||||
conn.execute(f"DELETE FROM credits WHERE {owner_col}=?", (owner_id,))
|
||||
|
||||
def person_id(p):
|
||||
tid = p.get("tmdb_id")
|
||||
if tid is not None:
|
||||
conn.execute("INSERT OR IGNORE INTO people (name, tmdb_id, photo_url) VALUES (?, ?, ?)",
|
||||
(p["name"], tid, p.get("photo_url")))
|
||||
row = conn.execute("SELECT id FROM people WHERE tmdb_id=?", (tid,)).fetchone()
|
||||
else:
|
||||
conn.execute("INSERT INTO people (name, photo_url) VALUES (?, ?)",
|
||||
(p["name"], p.get("photo_url")))
|
||||
row = conn.execute("SELECT last_insert_rowid() AS id").fetchone()
|
||||
pid = row["id"] if row else None
|
||||
if pid and p.get("photo_url"):
|
||||
conn.execute("UPDATE people SET photo_url=COALESCE(NULLIF(photo_url, ''), ?) WHERE id=?",
|
||||
(p["photo_url"], pid))
|
||||
return pid
|
||||
|
||||
def add(group, department, job_default):
|
||||
for i, c in enumerate(group or []):
|
||||
if not c.get("name"):
|
||||
continue
|
||||
pid = person_id(c)
|
||||
if not pid:
|
||||
continue
|
||||
conn.execute(
|
||||
f"INSERT INTO credits (person_id, {owner_col}, department, job, character, sort_order) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(pid, owner_id, department, c.get("job") or job_default, c.get("character"), i))
|
||||
add(cast, "cast", "Actor")
|
||||
add(crew, "crew", None)
|
||||
|
||||
@staticmethod
|
||||
def _credits_for(conn, owner_col: str, owner_id: int, cast_limit: int = 18) -> dict:
|
||||
rows = conn.execute(
|
||||
"SELECT p.name, p.photo_url, c.department, c.job, c.character "
|
||||
f"FROM credits c JOIN people p ON p.id = c.person_id WHERE c.{owner_col}=? "
|
||||
"ORDER BY c.department, c.sort_order", (owner_id,)).fetchall()
|
||||
cast = [{"name": r["name"], "character": r["character"], "photo": r["photo_url"]}
|
||||
for r in rows if r["department"] == "cast"][:cast_limit]
|
||||
crew = [{"name": r["name"], "job": r["job"]} for r in rows if r["department"] == "crew"]
|
||||
return {"cast": cast, "crew": crew}
|
||||
|
||||
def upsert_movie(self, server_source: str, item: dict) -> int:
|
||||
"""Insert/update one movie (keyed on server id) and its file. Returns row id."""
|
||||
conn = self._get_connection()
|
||||
|
|
@ -813,6 +868,7 @@ class VideoDatabase:
|
|||
if not show:
|
||||
return None
|
||||
genres = self._genres_for(conn, "show_genres", "show_id", show_id)
|
||||
credits = self._credits_for(conn, "show_id", show_id)
|
||||
seasons = conn.execute(
|
||||
"SELECT id, season_number, title, overview, "
|
||||
"(poster_url IS NOT NULL AND poster_url<>'') AS has_poster "
|
||||
|
|
@ -867,7 +923,7 @@ class VideoDatabase:
|
|||
"content_rating": show["content_rating"], "runtime_minutes": show["runtime_minutes"],
|
||||
"tagline": show["tagline"], "rating": show["rating"],
|
||||
"first_air_date": show["first_air_date"], "last_air_date": show["last_air_date"],
|
||||
"genres": genres,
|
||||
"genres": genres, "cast": credits["cast"], "crew": credits["crew"],
|
||||
"tmdb_id": show["tmdb_id"], "tvdb_id": show["tvdb_id"], "imdb_id": show["imdb_id"],
|
||||
"has_poster": bool(show["poster_url"]), "has_backdrop": bool(show["backdrop_url"]),
|
||||
"monitored": bool(show["monitored"]),
|
||||
|
|
@ -900,6 +956,7 @@ class VideoDatabase:
|
|||
if not m:
|
||||
return None
|
||||
genres = self._genres_for(conn, "movie_genres", "movie_id", movie_id)
|
||||
credits = self._credits_for(conn, "movie_id", movie_id)
|
||||
f = conn.execute(
|
||||
"SELECT resolution, quality, video_codec, audio_codec, size_bytes "
|
||||
"FROM media_files WHERE movie_id=? ORDER BY size_bytes DESC LIMIT 1",
|
||||
|
|
@ -912,6 +969,7 @@ class VideoDatabase:
|
|||
"release_date": m["release_date"], "runtime_minutes": m["runtime_minutes"],
|
||||
"content_rating": m["content_rating"], "tagline": m["tagline"],
|
||||
"rating": m["rating"], "rating_critic": m["rating_critic"], "genres": genres,
|
||||
"cast": credits["cast"], "crew": credits["crew"],
|
||||
"tmdb_id": m["tmdb_id"], "imdb_id": m["imdb_id"],
|
||||
"has_poster": bool(m["poster_url"]), "has_backdrop": bool(m["backdrop_url"]),
|
||||
"owned": bool(m["has_file"]), "monitored": bool(m["monitored"]),
|
||||
|
|
|
|||
|
|
@ -188,6 +188,33 @@ CREATE TABLE IF NOT EXISTS show_genres (
|
|||
CREATE INDEX IF NOT EXISTS idx_movie_genres_genre ON movie_genres(genre_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_show_genres_genre ON show_genres(genre_id);
|
||||
|
||||
-- ── People + credits (cast & crew; normalised, no blob) ─────────────────────
|
||||
-- A person appears in many titles; deduped by their provider id. Each credit
|
||||
-- belongs to exactly one movie OR show (separate nullable FKs + CHECK, no
|
||||
-- polymorphic id), mirroring the media_files/downloads pattern.
|
||||
CREATE TABLE IF NOT EXISTS people (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
tmdb_id INTEGER UNIQUE,
|
||||
photo_url TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_people_name ON people(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credits (
|
||||
id INTEGER PRIMARY KEY,
|
||||
person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
|
||||
movie_id INTEGER REFERENCES movies(id) ON DELETE CASCADE,
|
||||
show_id INTEGER REFERENCES shows(id) ON DELETE CASCADE,
|
||||
department TEXT NOT NULL, -- 'cast' | 'crew'
|
||||
job TEXT, -- Director | Writer | Creator (crew); 'Actor' (cast)
|
||||
character TEXT, -- the role played (cast)
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
CHECK ((movie_id IS NOT NULL) + (show_id IS NOT NULL) = 1)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_credits_movie ON credits(movie_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_credits_show ON credits(show_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_credits_person ON credits(person_id);
|
||||
|
||||
-- ── Content: YouTube (channels → videos) ────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id INTEGER PRIMARY KEY,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ EXPECTED_TABLES = {
|
|||
"meta", "root_folders", "quality_profiles", "video_settings",
|
||||
"movies", "shows", "seasons", "episodes", "channels", "channel_videos",
|
||||
"media_files", "downloads", "activity",
|
||||
"genres", "movie_genres", "show_genres",
|
||||
"genres", "movie_genres", "show_genres", "people", "credits",
|
||||
}
|
||||
EXPECTED_VIEWS = {"v_watchlist", "v_wishlist", "v_calendar"}
|
||||
|
||||
|
|
@ -543,6 +543,35 @@ def test_enrichment_backfills_genres_when_item_has_none(db):
|
|||
assert db.movie_detail(mid)["genres"] == ["Comedy", "Drama"]
|
||||
|
||||
|
||||
def test_enrichment_backfills_cast_and_crew(db):
|
||||
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []})
|
||||
db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=1, metadata={
|
||||
"cast": [
|
||||
{"name": "Aidan Gillen", "tmdb_id": 39388, "character": "James Cole",
|
||||
"photo_url": "https://img/ag.jpg"},
|
||||
{"name": "Amanda Schull", "tmdb_id": 84223, "character": "Cassie"}],
|
||||
"crew": [{"name": "Terry Matalas", "tmdb_id": 1, "job": "Creator"}]})
|
||||
d = db.show_detail(sid)
|
||||
assert [c["name"] for c in d["cast"]] == ["Aidan Gillen", "Amanda Schull"] # billing order
|
||||
assert d["cast"][0]["character"] == "James Cole" and d["cast"][0]["photo"] == "https://img/ag.jpg"
|
||||
assert d["crew"] == [{"name": "Terry Matalas", "job": "Creator"}]
|
||||
# People are deduped across titles by tmdb_id.
|
||||
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "M"})
|
||||
db.enrichment_apply("tmdb", "movie", mid, matched=True, external_id=2,
|
||||
metadata={"cast": [{"name": "Aidan Gillen", "tmdb_id": 39388, "character": "X"}]})
|
||||
with db.connect() as c:
|
||||
assert c.execute("SELECT COUNT(*) FROM people WHERE tmdb_id=39388").fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_enrichment_does_not_clobber_existing_credits(db):
|
||||
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []})
|
||||
db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=1,
|
||||
metadata={"cast": [{"name": "First", "tmdb_id": 1}]})
|
||||
db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=1,
|
||||
metadata={"cast": [{"name": "Second", "tmdb_id": 2}]})
|
||||
assert [c["name"] for c in db.show_detail(sid)["cast"]] == ["First"] # kept, not replaced
|
||||
|
||||
|
||||
def test_enrichment_backfills_season_posters_only_when_missing(db):
|
||||
# Season 1 has no art (server didn't provide it); season 2 has server art.
|
||||
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": [
|
||||
|
|
|
|||
|
|
@ -199,6 +199,26 @@ def test_tmdb_season_episodes_parses(monkeypatch):
|
|||
assert "still_url" not in res["episodes"][1] # no still_path → omitted
|
||||
|
||||
|
||||
def test_tmdb_parses_cast_and_crew(monkeypatch):
|
||||
class _Resp:
|
||||
def __init__(self, b): self._b = b
|
||||
def raise_for_status(self): pass
|
||||
def json(self): return self._b
|
||||
detail = {"overview": "O", "external_ids": {}, "created_by": [{"id": 9, "name": "The Creator"}],
|
||||
"credits": {
|
||||
"cast": [{"id": 1, "name": "Lead", "character": "Hero", "profile_path": "/p.jpg"},
|
||||
{"id": 2, "name": "Support", "character": "Sidekick"}],
|
||||
"crew": [{"id": 3, "name": "Dir", "job": "Director"},
|
||||
{"id": 4, "name": "Edit", "job": "Editor"}]}} # Editor filtered out
|
||||
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
|
||||
m = TMDBClient("KEY").match("show", "S", 2020, known_id=1396)["metadata"]
|
||||
assert [c["name"] for c in m["cast"]] == ["Lead", "Support"]
|
||||
assert m["cast"][0]["photo_url"] == "https://image.tmdb.org/t/p/w185/p.jpg"
|
||||
jobs = {(c["name"], c["job"]) for c in m["crew"]}
|
||||
assert ("Dir", "Director") in jobs and ("The Creator", "Creator") in jobs
|
||||
assert not any(c["name"] == "Edit" for c in m["crew"]) # non-headline job dropped
|
||||
|
||||
|
||||
def test_tmdb_show_returns_season_posters(monkeypatch):
|
||||
class _Resp:
|
||||
def __init__(self, b): self._b = b
|
||||
|
|
|
|||
|
|
@ -338,7 +338,7 @@ def test_show_detail_subpage_present():
|
|||
# Netflix billboard + episodes containers the renderer fills.
|
||||
for hook in ('data-vd-backdrop', 'data-vd-poster', 'data-vd-title', 'data-vd-meta',
|
||||
'data-vd-overview', 'data-vd-actions', 'data-vd-view-toggle',
|
||||
'data-vd-season-nav', 'data-vd-episodes'):
|
||||
'data-vd-season-nav', 'data-vd-episodes', 'data-vd-cast', 'data-vd-crew'):
|
||||
assert hook in block, hook
|
||||
# Back button reuses the shared data-video-goto nav (no inline handler).
|
||||
assert 'data-video-goto="video-library"' in block
|
||||
|
|
|
|||
|
|
@ -854,6 +854,12 @@
|
|||
chosen by the toggle above; all drive the same selection. -->
|
||||
<div class="vd-season-nav" data-vd-season-nav></div>
|
||||
<div class="vd-episodes" data-vd-episodes></div>
|
||||
<!-- Cast & crew (TMDB) — populated by video-detail.js. -->
|
||||
<div class="vd-cast-section" data-vd-cast-section hidden>
|
||||
<h2 class="vd-section-h">Cast & Crew</h2>
|
||||
<div class="vd-crew" data-vd-crew></div>
|
||||
<div class="vd-cast" data-vd-cast></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -145,6 +145,38 @@
|
|||
return '<span class="vd-genre">' + esc(gn) + '</span>';
|
||||
}).join('');
|
||||
}
|
||||
renderCast(d);
|
||||
}
|
||||
|
||||
function renderCast(d) {
|
||||
var section = q('[data-vd-cast-section]');
|
||||
if (!section) return;
|
||||
var cast = d.cast || [], crew = d.crew || [];
|
||||
if (!cast.length && !crew.length) { section.hidden = true; return; }
|
||||
section.hidden = false;
|
||||
|
||||
var crewHost = q('[data-vd-crew]');
|
||||
if (crewHost) {
|
||||
// Group crew by job (Creator / Director / Writer …) → "Job: A, B".
|
||||
var byJob = {};
|
||||
crew.forEach(function (c) { (byJob[c.job || 'Crew'] = byJob[c.job || 'Crew'] || []).push(c.name); });
|
||||
crewHost.innerHTML = Object.keys(byJob).map(function (job) {
|
||||
return '<span class="vd-crew-item"><span class="vd-crew-job">' + esc(job) +
|
||||
(byJob[job].length > 1 ? 's' : '') + '</span> ' + esc(byJob[job].join(', ')) + '</span>';
|
||||
}).join('');
|
||||
}
|
||||
var castHost = q('[data-vd-cast]');
|
||||
if (castHost) {
|
||||
castHost.innerHTML = cast.map(function (p) {
|
||||
var img = p.photo
|
||||
? '<img class="vd-cast-photo" src="' + esc(p.photo) + '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">'
|
||||
: '<span class="vd-cast-photo vd-cast-photo--ph">' + esc((p.name || '?').charAt(0)) + '</span>';
|
||||
return '<div class="vd-cast-card">' + img +
|
||||
'<span class="vd-cast-name">' + esc(p.name) + '</span>' +
|
||||
(p.character ? '<span class="vd-cast-char">' + esc(p.character) + '</span>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function renderActions(d) {
|
||||
|
|
|
|||
|
|
@ -681,3 +681,29 @@ body[data-side="video"] .dashboard-header-sweep {
|
|||
#vem-overlay .em-card--current {
|
||||
box-shadow: 0 0 18px rgba(var(--em-accent-rgb), 0.28);
|
||||
}
|
||||
|
||||
/* ── Cast & crew section (detail page) ───────────────────────────────────── */
|
||||
.vd-cast-section { margin-top: 46px; }
|
||||
.vd-section-h { font-size: 22px; font-weight: 800; color: #fff; margin: 0 0 16px; }
|
||||
.vd-crew { display: flex; flex-wrap: wrap; gap: 8px 24px; margin-bottom: 24px;
|
||||
color: rgba(255, 255, 255, 0.72); font-size: 14px; }
|
||||
.vd-crew-job { color: rgba(255, 255, 255, 0.45); font-weight: 600; }
|
||||
.vd-cast { display: flex; gap: 18px; overflow-x: auto; padding-bottom: 12px; scroll-snap-type: x proximity; }
|
||||
.vd-cast::-webkit-scrollbar { height: 8px; }
|
||||
.vd-cast::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
|
||||
.vd-cast-card { flex: 0 0 116px; width: 116px; scroll-snap-align: start; text-align: center; }
|
||||
.vd-cast-photo {
|
||||
width: 116px; height: 116px; border-radius: 50%; object-fit: cover; display: block; margin: 0 auto 10px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45); border: 2px solid rgba(255, 255, 255, 0.08);
|
||||
transition: transform 0.25s ease, border-color 0.25s ease;
|
||||
}
|
||||
.vd-cast-photo--ph {
|
||||
display: flex; align-items: center; justify-content: center; font-size: 40px; font-weight: 800;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
background: linear-gradient(135deg, rgba(var(--vd-accent-rgb), 0.32), rgba(var(--vd-accent-rgb), 0.1));
|
||||
}
|
||||
.vd-cast-card:hover .vd-cast-photo { transform: translateY(-3px); border-color: rgba(var(--vd-accent-rgb), 0.6); }
|
||||
.vd-cast-name { display: block; font-size: 13.5px; font-weight: 700; color: #fff; line-height: 1.3;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vd-cast-char { display: block; font-size: 12px; color: rgba(255, 255, 255, 0.55); line-height: 1.3;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue