kryten-webqueue 0.6.6__tar.gz → 0.7.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/CHANGELOG.md +18 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/PKG-INFO +1 -1
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/catalog/db.py +128 -4
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/catalog/sync.py +35 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/queue/shadow.py +11 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/routes/catalog.py +6 -5
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/routes/pages.py +10 -3
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/static/css/main.css +131 -18
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/templates/catalog/browse.html +18 -7
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/templates/queue/index.html +47 -14
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/pyproject.toml +1 -1
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/.gitignore +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/README.md +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/config.example.json +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/ws/manager.py +0 -0
|
@@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
## [0.7.1] - 2026-06-08
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Now Playing box redesign.** The title now spans the full card width, the cover renders as a 2:3 poster, the elapsed/total time and progress bar are stacked with **Remaining** shown as its own line under the bar, and a details section beneath the poster shows the item description plus category and tag chips. The now-playing payload is enriched with `description`, `categories`, and `tags` from the catalog.
|
|
12
|
+
- **"Hide Previous" now defaults to ON** on the Queue page, so the view opens focused on what's still to come.
|
|
13
|
+
|
|
14
|
+
## [0.7.0] - 2026-06-08
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **Category & tag search facets on the catalog listing.** The category dropdown is now populated with the distinct MediaCMS categories that actually have items, and a new **Tags** dropdown sits beside it. Selecting either narrows the listing; both are preserved across pagination. Catalog sync now fetches per-item `categories_info`/`tags_info` from the MediaCMS media-detail endpoint (the bulk `manage_media` list omits them) and maintains the `categories`/`tags` join tables. Facet dropdowns only surface categories/tags with at least one item, and tags are ordered by usage.
|
|
19
|
+
- **"Hide Previous" toggle on the Queue page** — a switch in the Up Next header hides every item before the currently-playing one, for a cleaner view of what's still to come.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **Queue page no longer overflows the viewport width.** The three-column queue grid now uses `minmax(0, …)` tracks (and `min-width: 0` on its columns) so long titles or the no-wrap Now Playing times can't blow the layout past 100% width. The queue column was also slimmed slightly.
|
|
24
|
+
|
|
7
25
|
## [0.6.6] - 2026-06-08
|
|
8
26
|
|
|
9
27
|
### Changed
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import aiosqlite
|
|
2
|
+
import re
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
from datetime import datetime, UTC
|
|
4
5
|
|
|
5
6
|
|
|
7
|
+
def _slugify(text: str) -> str:
|
|
8
|
+
"""Derive a URL-safe slug from a category title."""
|
|
9
|
+
s = (text or "").strip().lower()
|
|
10
|
+
s = re.sub(r"[^\w\s-]", "", s)
|
|
11
|
+
s = re.sub(r"[\s_-]+", "-", s)
|
|
12
|
+
return s.strip("-") or "untitled"
|
|
13
|
+
|
|
14
|
+
|
|
6
15
|
MIGRATIONS = [
|
|
7
16
|
# v1: Migration tracking table
|
|
8
17
|
"""
|
|
@@ -236,7 +245,7 @@ class Database:
|
|
|
236
245
|
|
|
237
246
|
# --- Catalog ---
|
|
238
247
|
|
|
239
|
-
async def browse(self, *, category: str | None = None, page: int = 1, per_page: int = 24) -> list[dict]:
|
|
248
|
+
async def browse(self, *, category: str | None = None, tag: str | None = None, page: int = 1, per_page: int = 24) -> list[dict]:
|
|
240
249
|
query = """
|
|
241
250
|
SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.thumbnail_url, c.manifest_url
|
|
242
251
|
FROM catalog c
|
|
@@ -256,6 +265,15 @@ class Database:
|
|
|
256
265
|
)
|
|
257
266
|
"""
|
|
258
267
|
params.append(category)
|
|
268
|
+
if tag:
|
|
269
|
+
query += """
|
|
270
|
+
AND c.friendly_token IN (
|
|
271
|
+
SELECT ct.friendly_token FROM catalog_tags ct
|
|
272
|
+
JOIN tags t ON ct.tag_id = t.id
|
|
273
|
+
WHERE t.name = ?
|
|
274
|
+
)
|
|
275
|
+
"""
|
|
276
|
+
params.append(tag)
|
|
259
277
|
# Quality-weighted ordering so the landing page leads with presentable
|
|
260
278
|
# items instead of alphabetical junk. No curation required — every signal
|
|
261
279
|
# is derived from existing data:
|
|
@@ -278,7 +296,7 @@ class Database:
|
|
|
278
296
|
params.extend([per_page, (page - 1) * per_page])
|
|
279
297
|
return await self._fetch_all(query, params)
|
|
280
298
|
|
|
281
|
-
async def browse_count(self, *, category: str | None = None) -> int:
|
|
299
|
+
async def browse_count(self, *, category: str | None = None, tag: str | None = None) -> int:
|
|
282
300
|
query = """
|
|
283
301
|
SELECT COUNT(*) as cnt FROM catalog c
|
|
284
302
|
WHERE c.friendly_token NOT IN (
|
|
@@ -297,6 +315,15 @@ class Database:
|
|
|
297
315
|
)
|
|
298
316
|
"""
|
|
299
317
|
params.append(category)
|
|
318
|
+
if tag:
|
|
319
|
+
query += """
|
|
320
|
+
AND c.friendly_token IN (
|
|
321
|
+
SELECT ct.friendly_token FROM catalog_tags ct
|
|
322
|
+
JOIN tags t ON ct.tag_id = t.id
|
|
323
|
+
WHERE t.name = ?
|
|
324
|
+
)
|
|
325
|
+
"""
|
|
326
|
+
params.append(tag)
|
|
300
327
|
row = await self._fetch_one(query, params)
|
|
301
328
|
return row["cnt"] if row else 0
|
|
302
329
|
|
|
@@ -347,6 +374,31 @@ class Database:
|
|
|
347
374
|
async def get_item_admin(self, friendly_token: str) -> dict | None:
|
|
348
375
|
return await self._fetch_one("SELECT * FROM catalog WHERE friendly_token = ?", [friendly_token])
|
|
349
376
|
|
|
377
|
+
async def get_item_facets(self, friendly_token: str) -> dict:
|
|
378
|
+
"""Return category and tag names for a single catalog item."""
|
|
379
|
+
cats = await self._fetch_all(
|
|
380
|
+
"""
|
|
381
|
+
SELECT cat.name FROM catalog_categories cc
|
|
382
|
+
JOIN categories cat ON cc.category_id = cat.id
|
|
383
|
+
WHERE cc.friendly_token = ?
|
|
384
|
+
ORDER BY cat.name
|
|
385
|
+
""",
|
|
386
|
+
[friendly_token],
|
|
387
|
+
)
|
|
388
|
+
tags = await self._fetch_all(
|
|
389
|
+
"""
|
|
390
|
+
SELECT t.name FROM catalog_tags ct
|
|
391
|
+
JOIN tags t ON ct.tag_id = t.id
|
|
392
|
+
WHERE ct.friendly_token = ?
|
|
393
|
+
ORDER BY t.name
|
|
394
|
+
""",
|
|
395
|
+
[friendly_token],
|
|
396
|
+
)
|
|
397
|
+
return {
|
|
398
|
+
"categories": [c["name"] for c in cats],
|
|
399
|
+
"tags": [t["name"] for t in tags],
|
|
400
|
+
}
|
|
401
|
+
|
|
350
402
|
async def get_catalog_brief(self, tokens: list[str], manifest_urls: list[str]) -> dict[str, dict]:
|
|
351
403
|
"""Return a lookup of catalog metadata keyed by BOTH friendly_token and
|
|
352
404
|
manifest_url, for enriching queue-shadow items that may only carry one.
|
|
@@ -356,7 +408,7 @@ class Database:
|
|
|
356
408
|
return {}
|
|
357
409
|
placeholders = ",".join("?" * len(keys))
|
|
358
410
|
rows = await self._fetch_all(
|
|
359
|
-
"SELECT friendly_token, manifest_url, title, duration_sec, "
|
|
411
|
+
"SELECT friendly_token, manifest_url, title, description, duration_sec, "
|
|
360
412
|
"cover_art_path, thumbnail_url FROM catalog "
|
|
361
413
|
f"WHERE friendly_token IN ({placeholders}) OR manifest_url IN ({placeholders})",
|
|
362
414
|
keys + keys,
|
|
@@ -383,7 +435,79 @@ class Database:
|
|
|
383
435
|
return row is not None
|
|
384
436
|
|
|
385
437
|
async def get_categories(self) -> list[dict]:
|
|
386
|
-
|
|
438
|
+
"""Distinct categories that have at least one catalog item, for facets."""
|
|
439
|
+
return await self._fetch_all(
|
|
440
|
+
"""
|
|
441
|
+
SELECT c.id, c.name, c.slug, COUNT(cc.friendly_token) AS cnt
|
|
442
|
+
FROM categories c
|
|
443
|
+
JOIN catalog_categories cc ON cc.category_id = c.id
|
|
444
|
+
GROUP BY c.id, c.name, c.slug
|
|
445
|
+
ORDER BY c.name
|
|
446
|
+
"""
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
async def get_tags(self, *, limit: int = 100) -> list[dict]:
|
|
450
|
+
"""Most-used tags that have at least one catalog item, for facets."""
|
|
451
|
+
return await self._fetch_all(
|
|
452
|
+
"""
|
|
453
|
+
SELECT t.id, t.name, COUNT(ct.friendly_token) AS cnt
|
|
454
|
+
FROM tags t
|
|
455
|
+
JOIN catalog_tags ct ON ct.tag_id = t.id
|
|
456
|
+
GROUP BY t.id, t.name
|
|
457
|
+
ORDER BY cnt DESC, t.name ASC
|
|
458
|
+
LIMIT ?
|
|
459
|
+
""",
|
|
460
|
+
[limit],
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
async def upsert_category(self, name: str) -> int:
|
|
464
|
+
"""Insert a category by name (deriving a unique slug) and return its id."""
|
|
465
|
+
existing = await self._fetch_one("SELECT id FROM categories WHERE name = ?", [name])
|
|
466
|
+
if existing:
|
|
467
|
+
return existing["id"]
|
|
468
|
+
base = _slugify(name)
|
|
469
|
+
slug, n = base, 1
|
|
470
|
+
while await self._fetch_one("SELECT 1 FROM categories WHERE slug = ?", [slug]):
|
|
471
|
+
n += 1
|
|
472
|
+
slug = f"{base}-{n}"
|
|
473
|
+
cursor = await self._db.execute(
|
|
474
|
+
"INSERT INTO categories (name, slug) VALUES (?, ?)", [name, slug]
|
|
475
|
+
)
|
|
476
|
+
await self._db.commit()
|
|
477
|
+
return cursor.lastrowid
|
|
478
|
+
|
|
479
|
+
async def upsert_tag(self, name: str) -> int:
|
|
480
|
+
"""Insert a tag by name and return its id."""
|
|
481
|
+
existing = await self._fetch_one("SELECT id FROM tags WHERE name = ?", [name])
|
|
482
|
+
if existing:
|
|
483
|
+
return existing["id"]
|
|
484
|
+
cursor = await self._db.execute("INSERT INTO tags (name) VALUES (?)", [name])
|
|
485
|
+
await self._db.commit()
|
|
486
|
+
return cursor.lastrowid
|
|
487
|
+
|
|
488
|
+
async def set_catalog_categories(self, friendly_token: str, category_ids: list[int]):
|
|
489
|
+
"""Replace the category memberships for a catalog item."""
|
|
490
|
+
await self._db.execute(
|
|
491
|
+
"DELETE FROM catalog_categories WHERE friendly_token = ?", [friendly_token]
|
|
492
|
+
)
|
|
493
|
+
for cid in category_ids:
|
|
494
|
+
await self._db.execute(
|
|
495
|
+
"INSERT OR IGNORE INTO catalog_categories (friendly_token, category_id) VALUES (?, ?)",
|
|
496
|
+
[friendly_token, cid],
|
|
497
|
+
)
|
|
498
|
+
await self._db.commit()
|
|
499
|
+
|
|
500
|
+
async def set_catalog_tags(self, friendly_token: str, tag_ids: list[int]):
|
|
501
|
+
"""Replace the tag memberships for a catalog item."""
|
|
502
|
+
await self._db.execute(
|
|
503
|
+
"DELETE FROM catalog_tags WHERE friendly_token = ?", [friendly_token]
|
|
504
|
+
)
|
|
505
|
+
for tid in tag_ids:
|
|
506
|
+
await self._db.execute(
|
|
507
|
+
"INSERT OR IGNORE INTO catalog_tags (friendly_token, tag_id) VALUES (?, ?)",
|
|
508
|
+
[friendly_token, tid],
|
|
509
|
+
)
|
|
510
|
+
await self._db.commit()
|
|
387
511
|
|
|
388
512
|
async def insert_catalog(self, row: dict):
|
|
389
513
|
sql = """
|
|
@@ -129,6 +129,13 @@ class CatalogSync:
|
|
|
129
129
|
await self._db.insert_catalog(row)
|
|
130
130
|
stats["new"] += 1
|
|
131
131
|
|
|
132
|
+
# Categories & tags require a per-item detail fetch — the manage_media
|
|
133
|
+
# list serializer omits them. Best-effort; never fail the item over this.
|
|
134
|
+
try:
|
|
135
|
+
await self._sync_item_facets(token)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
logger.debug(f"Facet sync failed for {token}: {e}")
|
|
138
|
+
|
|
132
139
|
# Fetch TMDB/OMDB cover art if not already cached
|
|
133
140
|
if self._cover_art and not (existing and existing.get("cover_art_path")):
|
|
134
141
|
try:
|
|
@@ -137,6 +144,34 @@ class CatalogSync:
|
|
|
137
144
|
logger.debug(f"Cover art resolve failed for {token}: {e}")
|
|
138
145
|
await asyncio.sleep(0.25)
|
|
139
146
|
|
|
147
|
+
async def _sync_item_facets(self, token: str):
|
|
148
|
+
"""Populate category/tag memberships from the media detail endpoint.
|
|
149
|
+
|
|
150
|
+
The list (manage_media) serializer omits categories/tags, so we fetch
|
|
151
|
+
the per-item detail (``/api/v1/media/{token}``) which exposes
|
|
152
|
+
``categories_info`` and ``tags_info``.
|
|
153
|
+
"""
|
|
154
|
+
resp = await self._client.get(f"{self._url}/api/v1/media/{token}")
|
|
155
|
+
if resp.status_code != 200:
|
|
156
|
+
return
|
|
157
|
+
data = resp.json()
|
|
158
|
+
|
|
159
|
+
cat_names = [
|
|
160
|
+
c.get("title")
|
|
161
|
+
for c in (data.get("categories_info") or [])
|
|
162
|
+
if isinstance(c, dict) and c.get("title")
|
|
163
|
+
]
|
|
164
|
+
tag_names = []
|
|
165
|
+
for t in (data.get("tags_info") or []):
|
|
166
|
+
name = t.get("title") if isinstance(t, dict) else t
|
|
167
|
+
if name:
|
|
168
|
+
tag_names.append(name)
|
|
169
|
+
|
|
170
|
+
cat_ids = [await self._db.upsert_category(n) for n in cat_names]
|
|
171
|
+
tag_ids = [await self._db.upsert_tag(n) for n in tag_names]
|
|
172
|
+
await self._db.set_catalog_categories(token, cat_ids)
|
|
173
|
+
await self._db.set_catalog_tags(token, tag_ids)
|
|
174
|
+
|
|
140
175
|
def _build_manifest_url(self, media: dict) -> str:
|
|
141
176
|
# CyTube custom media ("cm") requires the manifest JSON URL, NOT the
|
|
142
177
|
# human watch page (/view?m=TOKEN). MediaCMS exposes a CyTube manifest at
|
|
@@ -201,6 +201,8 @@ class QueueShadow:
|
|
|
201
201
|
if meta:
|
|
202
202
|
np.setdefault("cover_art_path", meta.get("cover_art_path"))
|
|
203
203
|
np.setdefault("thumbnail_url", meta.get("thumbnail_url"))
|
|
204
|
+
if not np.get("description"):
|
|
205
|
+
np["description"] = meta.get("description")
|
|
204
206
|
if not np.get("friendly_token"):
|
|
205
207
|
np["friendly_token"] = meta.get("friendly_token")
|
|
206
208
|
# Ensure now-playing carries a playlist uid so the frontend can
|
|
@@ -217,6 +219,15 @@ class QueueShadow:
|
|
|
217
219
|
):
|
|
218
220
|
np["uid"] = it.get("uid")
|
|
219
221
|
break
|
|
222
|
+
# Attach category/tag facets for the now-playing detail panel.
|
|
223
|
+
ft = np.get("friendly_token")
|
|
224
|
+
if ft:
|
|
225
|
+
try:
|
|
226
|
+
facets = await db.get_item_facets(ft)
|
|
227
|
+
np["categories"] = facets.get("categories") or []
|
|
228
|
+
np["tags"] = facets.get("tags") or []
|
|
229
|
+
except Exception:
|
|
230
|
+
logger.debug("Failed to load now-playing facets", exc_info=True)
|
|
220
231
|
state["now_playing"] = np
|
|
221
232
|
|
|
222
233
|
return state
|
|
@@ -6,13 +6,14 @@ router = APIRouter(prefix="/catalog", tags=["catalog"])
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
@router.get("/browse")
|
|
9
|
-
async def browse(request: Request, category: str | None = None,
|
|
10
|
-
user: dict = Depends(get_current_user)):
|
|
11
|
-
"""Browse catalog with optional category filter."""
|
|
9
|
+
async def browse(request: Request, category: str | None = None, tag: str | None = None,
|
|
10
|
+
page: int = 1, user: dict = Depends(get_current_user)):
|
|
11
|
+
"""Browse catalog with optional category/tag filter."""
|
|
12
12
|
db = request.app.state.db
|
|
13
|
-
items = await db.browse(category=category, page=page)
|
|
13
|
+
items = await db.browse(category=category, tag=tag, page=page)
|
|
14
14
|
categories = await db.get_categories()
|
|
15
|
-
|
|
15
|
+
tags = await db.get_tags()
|
|
16
|
+
return {"items": items, "categories": categories, "tags": tags, "page": page}
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
@router.get("/search")
|
|
@@ -40,22 +40,26 @@ async def login_page(request: Request):
|
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
@router.get("/catalog/browse", response_class=HTMLResponse)
|
|
43
|
-
async def catalog_browse_page(request: Request, category: str | None = None,
|
|
43
|
+
async def catalog_browse_page(request: Request, category: str | None = None,
|
|
44
|
+
tag: str | None = None, page: int = 1):
|
|
44
45
|
user = _get_user_or_none(request)
|
|
45
46
|
if not user:
|
|
46
47
|
return RedirectResponse("/auth/login")
|
|
47
48
|
db = request.app.state.db
|
|
48
|
-
items = await db.browse(category=category, page=page)
|
|
49
|
-
total = await db.browse_count(category=category)
|
|
49
|
+
items = await db.browse(category=category, tag=tag, page=page)
|
|
50
|
+
total = await db.browse_count(category=category, tag=tag)
|
|
50
51
|
total_pages = max(1, (total + 23) // 24)
|
|
51
52
|
categories = await db.get_categories()
|
|
53
|
+
tags = await db.get_tags()
|
|
52
54
|
return templates.TemplateResponse(request, "catalog/browse.html", {
|
|
53
55
|
"user": user,
|
|
54
56
|
"items": items,
|
|
55
57
|
"categories": categories,
|
|
58
|
+
"tags": tags,
|
|
56
59
|
"page": page,
|
|
57
60
|
"total_pages": total_pages,
|
|
58
61
|
"active_category": category,
|
|
62
|
+
"active_tag": tag,
|
|
59
63
|
"query": None,
|
|
60
64
|
})
|
|
61
65
|
|
|
@@ -72,13 +76,16 @@ async def catalog_search_page(request: Request, q: str = "", page: int = 1):
|
|
|
72
76
|
total = await db.search_count(q)
|
|
73
77
|
total_pages = max(1, (total + 23) // 24)
|
|
74
78
|
categories = await db.get_categories()
|
|
79
|
+
tags = await db.get_tags()
|
|
75
80
|
return templates.TemplateResponse(request, "catalog/browse.html", {
|
|
76
81
|
"user": user,
|
|
77
82
|
"items": items,
|
|
78
83
|
"categories": categories,
|
|
84
|
+
"tags": tags,
|
|
79
85
|
"page": page,
|
|
80
86
|
"total_pages": total_pages,
|
|
81
87
|
"active_category": None,
|
|
88
|
+
"active_tag": None,
|
|
82
89
|
"query": q,
|
|
83
90
|
})
|
|
84
91
|
|
|
@@ -139,12 +139,18 @@ a:hover {
|
|
|
139
139
|
color: var(--text-primary);
|
|
140
140
|
width: 300px;
|
|
141
141
|
}
|
|
142
|
+
.category-filter {
|
|
143
|
+
display: flex;
|
|
144
|
+
gap: 0.5rem;
|
|
145
|
+
flex-wrap: wrap;
|
|
146
|
+
}
|
|
142
147
|
.category-filter select {
|
|
143
148
|
padding: 0.5rem;
|
|
144
149
|
border: 1px solid var(--border);
|
|
145
150
|
border-radius: var(--radius);
|
|
146
151
|
background: var(--bg-secondary);
|
|
147
152
|
color: var(--text-primary);
|
|
153
|
+
max-width: 220px;
|
|
148
154
|
}
|
|
149
155
|
|
|
150
156
|
.catalog-grid {
|
|
@@ -225,15 +231,77 @@ a:hover {
|
|
|
225
231
|
/* Queue Page */
|
|
226
232
|
.queue-layout {
|
|
227
233
|
display: grid;
|
|
228
|
-
grid
|
|
234
|
+
/* minmax(0, …) lets grid tracks shrink below their content's intrinsic
|
|
235
|
+
width, which prevents a long title or nowrap row from blowing the grid
|
|
236
|
+
(and the page) past 100% viewport width. */
|
|
237
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1.7fr) 220px;
|
|
229
238
|
gap: 2rem;
|
|
230
239
|
}
|
|
240
|
+
/* Allow each column's flex/overflow children to shrink instead of overflowing. */
|
|
241
|
+
.now-playing-section,
|
|
242
|
+
.queue-section,
|
|
243
|
+
.queue-info-sidebar {
|
|
244
|
+
min-width: 0;
|
|
245
|
+
}
|
|
231
246
|
@media (max-width: 900px) {
|
|
232
247
|
.queue-layout {
|
|
233
248
|
grid-template-columns: 1fr;
|
|
234
249
|
}
|
|
235
250
|
}
|
|
236
251
|
|
|
252
|
+
/* Up Next header with the Hide Previous toggle */
|
|
253
|
+
.queue-section-header {
|
|
254
|
+
display: flex;
|
|
255
|
+
align-items: center;
|
|
256
|
+
justify-content: space-between;
|
|
257
|
+
gap: 1rem;
|
|
258
|
+
flex-wrap: wrap;
|
|
259
|
+
}
|
|
260
|
+
.toggle-switch {
|
|
261
|
+
display: inline-flex;
|
|
262
|
+
align-items: center;
|
|
263
|
+
gap: 0.5rem;
|
|
264
|
+
cursor: pointer;
|
|
265
|
+
user-select: none;
|
|
266
|
+
font-size: 0.85rem;
|
|
267
|
+
color: var(--text-secondary);
|
|
268
|
+
}
|
|
269
|
+
.toggle-switch input {
|
|
270
|
+
position: absolute;
|
|
271
|
+
opacity: 0;
|
|
272
|
+
width: 0;
|
|
273
|
+
height: 0;
|
|
274
|
+
}
|
|
275
|
+
.toggle-slider {
|
|
276
|
+
position: relative;
|
|
277
|
+
width: 38px;
|
|
278
|
+
height: 20px;
|
|
279
|
+
background: var(--border);
|
|
280
|
+
border-radius: 10px;
|
|
281
|
+
transition: background 0.15s ease;
|
|
282
|
+
flex-shrink: 0;
|
|
283
|
+
}
|
|
284
|
+
.toggle-slider::before {
|
|
285
|
+
content: "";
|
|
286
|
+
position: absolute;
|
|
287
|
+
top: 2px;
|
|
288
|
+
left: 2px;
|
|
289
|
+
width: 16px;
|
|
290
|
+
height: 16px;
|
|
291
|
+
background: #fff;
|
|
292
|
+
border-radius: 50%;
|
|
293
|
+
transition: transform 0.15s ease;
|
|
294
|
+
}
|
|
295
|
+
.toggle-switch input:checked + .toggle-slider {
|
|
296
|
+
background: var(--accent);
|
|
297
|
+
}
|
|
298
|
+
.toggle-switch input:checked + .toggle-slider::before {
|
|
299
|
+
transform: translateX(18px);
|
|
300
|
+
}
|
|
301
|
+
.toggle-switch input:focus-visible + .toggle-slider {
|
|
302
|
+
box-shadow: 0 0 0 2px rgba(108, 92, 231, 0.5);
|
|
303
|
+
}
|
|
304
|
+
|
|
237
305
|
/* Keep the now-playing card pinned while scrolling a long queue. */
|
|
238
306
|
.now-playing-section {
|
|
239
307
|
position: sticky;
|
|
@@ -249,30 +317,42 @@ a:hover {
|
|
|
249
317
|
.now-playing-card {
|
|
250
318
|
background: var(--bg-card);
|
|
251
319
|
border-radius: var(--radius);
|
|
252
|
-
padding:
|
|
320
|
+
padding: 1.5rem;
|
|
253
321
|
display: flex;
|
|
254
|
-
|
|
255
|
-
|
|
322
|
+
flex-direction: column;
|
|
323
|
+
gap: 1rem;
|
|
256
324
|
/* Highlight the currently-playing item: subtle accent ring + glow. */
|
|
257
325
|
border: 1px solid var(--accent);
|
|
258
326
|
box-shadow: 0 0 0 1px rgba(108, 92, 231, 0.25), 0 6px 18px rgba(108, 92, 231, 0.18);
|
|
259
327
|
}
|
|
260
|
-
.
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
.np-info h3 {
|
|
265
|
-
margin-bottom: 0.5rem;
|
|
328
|
+
/* Title spans the full card width. */
|
|
329
|
+
.np-title {
|
|
330
|
+
margin: 0;
|
|
266
331
|
font-size: 1.4rem;
|
|
267
332
|
line-height: 1.25;
|
|
268
333
|
overflow-wrap: anywhere;
|
|
334
|
+
border-bottom: 1px solid var(--border);
|
|
335
|
+
padding-bottom: 0.75rem;
|
|
336
|
+
}
|
|
337
|
+
/* Poster (2:3) on the left, playback info on the right. */
|
|
338
|
+
.np-body {
|
|
339
|
+
display: flex;
|
|
340
|
+
gap: 1.25rem;
|
|
341
|
+
align-items: stretch;
|
|
342
|
+
}
|
|
343
|
+
.np-playback {
|
|
344
|
+
flex: 1;
|
|
345
|
+
min-width: 0;
|
|
346
|
+
display: flex;
|
|
347
|
+
flex-direction: column;
|
|
348
|
+
justify-content: flex-start;
|
|
349
|
+
gap: 0.5rem;
|
|
269
350
|
}
|
|
270
351
|
.np-progress {
|
|
271
352
|
height: 6px;
|
|
272
353
|
background: var(--border);
|
|
273
354
|
border-radius: 3px;
|
|
274
355
|
overflow: hidden;
|
|
275
|
-
margin: 0.75rem 0;
|
|
276
356
|
}
|
|
277
357
|
.progress-bar {
|
|
278
358
|
height: 100%;
|
|
@@ -280,8 +360,43 @@ a:hover {
|
|
|
280
360
|
transition: width 1s linear;
|
|
281
361
|
}
|
|
282
362
|
.np-time {
|
|
283
|
-
font-size: 0.
|
|
363
|
+
font-size: 0.95rem;
|
|
364
|
+
color: var(--text-primary);
|
|
365
|
+
}
|
|
366
|
+
/* Details below the poster/time row: category + tag chips, then description. */
|
|
367
|
+
.np-details {
|
|
368
|
+
border-top: 1px solid var(--border);
|
|
369
|
+
padding-top: 0.75rem;
|
|
370
|
+
display: flex;
|
|
371
|
+
flex-direction: column;
|
|
372
|
+
gap: 0.6rem;
|
|
373
|
+
}
|
|
374
|
+
.np-facets {
|
|
375
|
+
display: flex;
|
|
376
|
+
flex-wrap: wrap;
|
|
377
|
+
gap: 0.4rem;
|
|
378
|
+
}
|
|
379
|
+
.np-chip {
|
|
380
|
+
font-size: 0.7rem;
|
|
381
|
+
padding: 0.15rem 0.5rem;
|
|
382
|
+
border-radius: 999px;
|
|
383
|
+
white-space: nowrap;
|
|
384
|
+
}
|
|
385
|
+
.np-chip-cat {
|
|
386
|
+
background: var(--accent);
|
|
387
|
+
color: #fff;
|
|
388
|
+
}
|
|
389
|
+
.np-chip-tag {
|
|
390
|
+
background: var(--bg-elevated, #2a2a38);
|
|
391
|
+
color: var(--text-secondary);
|
|
392
|
+
border: 1px solid var(--border);
|
|
393
|
+
}
|
|
394
|
+
.np-description {
|
|
395
|
+
font-size: 0.85rem;
|
|
396
|
+
line-height: 1.5;
|
|
284
397
|
color: var(--text-secondary);
|
|
398
|
+
margin: 0;
|
|
399
|
+
overflow-wrap: anywhere;
|
|
285
400
|
}
|
|
286
401
|
|
|
287
402
|
.queue-list {
|
|
@@ -344,8 +459,8 @@ a:hover {
|
|
|
344
459
|
display: block;
|
|
345
460
|
}
|
|
346
461
|
.np-cover {
|
|
347
|
-
width:
|
|
348
|
-
|
|
462
|
+
width: 120px;
|
|
463
|
+
aspect-ratio: 2 / 3;
|
|
349
464
|
flex-shrink: 0;
|
|
350
465
|
border-radius: 8px;
|
|
351
466
|
overflow: hidden;
|
|
@@ -414,15 +529,13 @@ a:hover {
|
|
|
414
529
|
margin: 0.25rem 0;
|
|
415
530
|
}
|
|
416
531
|
.np-times {
|
|
417
|
-
display: flex;
|
|
418
|
-
justify-content: space-between;
|
|
419
|
-
gap: 1rem;
|
|
420
532
|
font-size: 0.9rem;
|
|
421
|
-
margin-top: 0.5rem;
|
|
422
533
|
white-space: nowrap;
|
|
423
534
|
}
|
|
424
535
|
.np-remaining {
|
|
536
|
+
font-size: 0.85rem;
|
|
425
537
|
color: var(--text-secondary);
|
|
538
|
+
margin-top: 0.1rem;
|
|
426
539
|
}
|
|
427
540
|
|
|
428
541
|
/* WebSocket status */
|
{kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
@@ -11,12 +11,18 @@
|
|
|
11
11
|
<button type="submit" class="btn btn-sm">Search</button>
|
|
12
12
|
</form>
|
|
13
13
|
<div class="category-filter">
|
|
14
|
-
<select id="category-select" onchange="
|
|
14
|
+
<select id="category-select" onchange="applyFacets()">
|
|
15
15
|
<option value="">All Categories</option>
|
|
16
16
|
{% for cat in categories %}
|
|
17
17
|
<option value="{{ cat.slug }}" {% if active_category == cat.slug %}selected{% endif %}>{{ cat.name }}</option>
|
|
18
18
|
{% endfor %}
|
|
19
19
|
</select>
|
|
20
|
+
<select id="tag-select" onchange="applyFacets()">
|
|
21
|
+
<option value="">All Tags</option>
|
|
22
|
+
{% for tag in tags %}
|
|
23
|
+
<option value="{{ tag.name }}" {% if active_tag == tag.name %}selected{% endif %}>{{ tag.name }}</option>
|
|
24
|
+
{% endfor %}
|
|
25
|
+
</select>
|
|
20
26
|
</div>
|
|
21
27
|
</div>
|
|
22
28
|
</div>
|
|
@@ -66,7 +72,7 @@
|
|
|
66
72
|
|
|
67
73
|
<div class="pagination">
|
|
68
74
|
{% if page > 1 %}
|
|
69
|
-
<a href="?page={{ page - 1 }}{% if query %}&q={{ query }}{% endif %}{% if active_category %}&category={{ active_category }}{% endif %}" class="btn btn-sm">← Prev</a>
|
|
75
|
+
<a href="?page={{ page - 1 }}{% if query %}&q={{ query }}{% endif %}{% if active_category %}&category={{ active_category }}{% endif %}{% if active_tag %}&tag={{ active_tag | urlencode }}{% endif %}" class="btn btn-sm">← Prev</a>
|
|
70
76
|
{% endif %}
|
|
71
77
|
|
|
72
78
|
{% set start_page = [1, page - 2] | max %}
|
|
@@ -75,23 +81,28 @@
|
|
|
75
81
|
{% if p == page %}
|
|
76
82
|
<span class="page-num page-current">{{ p }}</span>
|
|
77
83
|
{% else %}
|
|
78
|
-
<a href="?page={{ p }}{% if query %}&q={{ query }}{% endif %}{% if active_category %}&category={{ active_category }}{% endif %}" class="btn btn-sm btn-page">{{ p }}</a>
|
|
84
|
+
<a href="?page={{ p }}{% if query %}&q={{ query }}{% endif %}{% if active_category %}&category={{ active_category }}{% endif %}{% if active_tag %}&tag={{ active_tag | urlencode }}{% endif %}" class="btn btn-sm btn-page">{{ p }}</a>
|
|
79
85
|
{% endif %}
|
|
80
86
|
{% endfor %}
|
|
81
87
|
|
|
82
88
|
<span class="page-info">of {{ total_pages }}</span>
|
|
83
89
|
|
|
84
90
|
{% if page < total_pages %}
|
|
85
|
-
<a href="?page={{ page + 1 }}{% if query %}&q={{ query }}{% endif %}{% if active_category %}&category={{ active_category }}{% endif %}" class="btn btn-sm">Next →</a>
|
|
91
|
+
<a href="?page={{ page + 1 }}{% if query %}&q={{ query }}{% endif %}{% if active_category %}&category={{ active_category }}{% endif %}{% if active_tag %}&tag={{ active_tag | urlencode }}{% endif %}" class="btn btn-sm">Next →</a>
|
|
86
92
|
{% endif %}
|
|
87
93
|
</div>
|
|
88
94
|
{% endblock %}
|
|
89
95
|
|
|
90
96
|
{% block scripts %}
|
|
91
97
|
<script>
|
|
92
|
-
function
|
|
93
|
-
const
|
|
94
|
-
|
|
98
|
+
function applyFacets() {
|
|
99
|
+
const cat = document.getElementById('category-select').value;
|
|
100
|
+
const tag = document.getElementById('tag-select').value;
|
|
101
|
+
const params = new URLSearchParams();
|
|
102
|
+
if (cat) params.set('category', cat);
|
|
103
|
+
if (tag) params.set('tag', tag);
|
|
104
|
+
const qs = params.toString();
|
|
105
|
+
window.location.href = '/catalog/browse' + (qs ? '?' + qs : '');
|
|
95
106
|
}
|
|
96
107
|
// queueItem / playNext / queueAsAdmin are provided by /static/js/main.js
|
|
97
108
|
</script>
|
|
@@ -12,7 +12,14 @@
|
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
14
|
<div class="queue-section">
|
|
15
|
-
<
|
|
15
|
+
<div class="queue-section-header">
|
|
16
|
+
<h2>Up Next</h2>
|
|
17
|
+
<label class="toggle-switch" title="Hide items before the currently playing one">
|
|
18
|
+
<input type="checkbox" id="hide-previous-toggle" checked onchange="toggleHidePrevious(this.checked)">
|
|
19
|
+
<span class="toggle-slider"></span>
|
|
20
|
+
<span>Hide Previous</span>
|
|
21
|
+
</label>
|
|
22
|
+
</div>
|
|
16
23
|
<div id="queue-list" class="queue-list">
|
|
17
24
|
<p class="empty-state">Connecting...</p>
|
|
18
25
|
</div>
|
|
@@ -36,6 +43,13 @@
|
|
|
36
43
|
<script>
|
|
37
44
|
let ws = null;
|
|
38
45
|
let reconnectTimer = null;
|
|
46
|
+
let hidePrevious = true;
|
|
47
|
+
let lastState = null;
|
|
48
|
+
|
|
49
|
+
function toggleHidePrevious(checked) {
|
|
50
|
+
hidePrevious = checked;
|
|
51
|
+
if (lastState) renderQueue(lastState);
|
|
52
|
+
}
|
|
39
53
|
|
|
40
54
|
function connectWebSocket() {
|
|
41
55
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
@@ -75,6 +89,7 @@ function connectWebSocket() {
|
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
function renderQueue(state) {
|
|
92
|
+
lastState = state;
|
|
78
93
|
const npEl = document.getElementById('now-playing');
|
|
79
94
|
const qlEl = document.getElementById('queue-list');
|
|
80
95
|
const sumEl = document.getElementById('queue-summary');
|
|
@@ -86,21 +101,34 @@ function renderQueue(state) {
|
|
|
86
101
|
// runtime (`duration` is a preformatted "HH:MM:SS" string).
|
|
87
102
|
const total = Number(np.seconds ?? np.duration_sec ?? 0) || 0;
|
|
88
103
|
const remaining = Math.max(0, total - elapsed);
|
|
104
|
+
const pct = total > 0 ? (elapsed / total * 100) : 0;
|
|
105
|
+
const cats = Array.isArray(np.categories) ? np.categories : [];
|
|
106
|
+
const tags = Array.isArray(np.tags) ? np.tags : [];
|
|
107
|
+
const facetHtml = [
|
|
108
|
+
...cats.map(c => `<span class="np-chip np-chip-cat">${escapeHtml(c)}</span>`),
|
|
109
|
+
...tags.map(t => `<span class="np-chip np-chip-tag">${escapeHtml(t)}</span>`),
|
|
110
|
+
].join('');
|
|
111
|
+
const desc = (np.description || '').trim();
|
|
89
112
|
npEl.innerHTML = `
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
<div class="np-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
<
|
|
113
|
+
<h3 class="np-title">${escapeHtml(np.title || 'Unknown')}</h3>
|
|
114
|
+
${np.paid_by ? `<div class="np-meta"><span class="np-user">Queued by ${escapeHtml(np.paid_by)}</span></div>` : ''}
|
|
115
|
+
<div class="np-body">
|
|
116
|
+
<div class="np-cover">${coverHtml(np)}</div>
|
|
117
|
+
<div class="np-playback">
|
|
118
|
+
<div class="np-times">
|
|
119
|
+
<span class="np-time">${formatTime(elapsed)} / ${formatTime(total)}</span>
|
|
120
|
+
</div>
|
|
121
|
+
<div class="np-progress">
|
|
122
|
+
<div class="progress-bar" style="width: ${pct}%"></div>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="np-remaining">${formatTime(remaining)} remaining</div>
|
|
102
125
|
</div>
|
|
103
126
|
</div>
|
|
127
|
+
${(desc || facetHtml) ? `
|
|
128
|
+
<div class="np-details">
|
|
129
|
+
${facetHtml ? `<div class="np-facets">${facetHtml}</div>` : ''}
|
|
130
|
+
${desc ? `<p class="np-description">${escapeHtml(desc)}</p>` : ''}
|
|
131
|
+
</div>` : ''}
|
|
104
132
|
`;
|
|
105
133
|
} else {
|
|
106
134
|
npEl.innerHTML = '<p class="empty-state">Nothing playing</p>';
|
|
@@ -112,7 +140,12 @@ function renderQueue(state) {
|
|
|
112
140
|
|
|
113
141
|
const nowUid = state.now_playing && state.now_playing.uid != null
|
|
114
142
|
? String(state.now_playing.uid) : null;
|
|
115
|
-
|
|
143
|
+
const nowIndex = nowUid != null
|
|
144
|
+
? state.items.findIndex(it => String(it.uid) === nowUid) : -1;
|
|
145
|
+
qlEl.innerHTML = state.items
|
|
146
|
+
.map((item, i) => ({ item, i }))
|
|
147
|
+
.filter(({ i }) => !(hidePrevious && nowIndex >= 0 && i < nowIndex))
|
|
148
|
+
.map(({ item, i }) => `
|
|
116
149
|
<div class="queue-item ${item.is_pay ? 'queue-item-paid' : ''} ${nowUid != null && String(item.uid) === nowUid ? 'queue-item-now-playing' : ''}" data-uid="${item.uid}">
|
|
117
150
|
<span class="qi-drag" title="Drag to reorder">☰</span>
|
|
118
151
|
<span class="qi-pos">${i + 1}</span>
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/templates/admin/playlists.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/templates/admin/queue_mgmt.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/templates/admin/schedules.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/templates/catalog/item_detail.html
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.6.6 → kryten_webqueue-0.7.1}/kryten_webqueue/templates/user/dashboard.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|