kryten-webqueue 0.7.1__tar.gz → 0.7.3__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.
Files changed (67) hide show
  1. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/CHANGELOG.md +9 -3
  2. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/catalog/db.py +30 -26
  4. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/queue/shadow.py +5 -7
  5. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/static/css/main.css +46 -48
  6. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/templates/queue/index.html +7 -13
  7. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/pyproject.toml +1 -1
  8. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/.github/workflows/python-publish.yml +0 -0
  9. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/.github/workflows/release.yml +0 -0
  10. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/.gitignore +0 -0
  11. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/README.md +0 -0
  12. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/config.example.json +0 -0
  13. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/deploy/kryten-webqueue.service +0 -0
  14. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/deploy/nginx-queue.conf +0 -0
  15. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/docs/IMPLEMENTATION_SPEC.md +0 -0
  16. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/docs/IMPL_API_GATE.md +0 -0
  17. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/docs/IMPL_ECONOMY.md +0 -0
  18. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/docs/IMPL_KRYTEN_PY.md +0 -0
  19. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/docs/IMPL_ROBOT.md +0 -0
  20. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/docs/PRE_PLAN_GAPS.md +0 -0
  21. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/docs/PRODUCT_PLAN.md +0 -0
  22. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/__init__.py +0 -0
  23. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/__main__.py +0 -0
  24. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/api_gate/__init__.py +0 -0
  25. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/api_gate/client.py +0 -0
  26. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/app.py +0 -0
  27. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/auth/__init__.py +0 -0
  28. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/auth/otp.py +0 -0
  29. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/auth/rate_limit.py +0 -0
  30. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/auth/session.py +0 -0
  31. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/catalog/__init__.py +0 -0
  32. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/catalog/images.py +0 -0
  33. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/catalog/sync.py +0 -0
  34. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/config.py +0 -0
  35. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/jobs/__init__.py +0 -0
  36. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/jobs/manager.py +0 -0
  37. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/playlists/__init__.py +0 -0
  38. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/playlists/fire.py +0 -0
  39. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/playlists/importer.py +0 -0
  40. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/playlists/scheduler.py +0 -0
  41. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/queue/__init__.py +0 -0
  42. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/queue/ordering.py +0 -0
  43. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/queue/poller.py +0 -0
  44. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/routes/__init__.py +0 -0
  45. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/routes/admin_jobs.py +0 -0
  46. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/routes/admin_playlists.py +0 -0
  47. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/routes/admin_queue.py +0 -0
  48. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/routes/admin_schedules.py +0 -0
  49. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/routes/auth.py +0 -0
  50. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/routes/catalog.py +0 -0
  51. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/routes/pages.py +0 -0
  52. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/routes/queue.py +0 -0
  53. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/routes/user.py +0 -0
  54. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/static/js/main.js +0 -0
  55. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/templates/admin/index.html +0 -0
  56. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/templates/admin/playlists.html +0 -0
  57. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  58. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/templates/admin/schedules.html +0 -0
  59. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/templates/auth/login.html +0 -0
  60. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/templates/base.html +0 -0
  61. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/templates/catalog/browse.html +0 -0
  62. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  63. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  64. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/templates/user/dashboard.html +0 -0
  65. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/ws/__init__.py +0 -0
  66. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/ws/handler.py +0 -0
  67. {kryten_webqueue-0.7.1 → kryten_webqueue-0.7.3}/kryten_webqueue/ws/manager.py +0 -0
@@ -4,12 +4,18 @@ 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
7
+ ## [0.7.3] - 2026-06-08
8
+
9
+ ### Fixed
10
+
11
+ - **Description line breaks now render.** MediaCMS stores descriptions as newline-delimited plain text (`Synopsis:` / `Tagline:` / `Cast & Crew:` sections separated by `\n`), but the catalog item-detail page collapsed those newlines into a single run-on paragraph. Both the item-detail description and the Now Playing card now use `white-space: pre-line` to preserve the line/paragraph breaks; the Now Playing card also normalizes legacy `\r\n` line endings.
12
+
13
+ ## [0.7.2] - 2026-06-08
8
14
 
9
15
  ### Changed
10
16
 
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.
17
+ - **Now Playing card redesigned for readability.** The card is now a vertical stack: the title spans the full width across the top (with a divider), then a row pairs a 2:3 poster with the time display, the progress bar, and the **Remaining** time directly under the bar. Below that, the item **description** and **category/tag chips** are shown when available. The now-playing state is enriched server-side with the catalog description and category/tag names for the playing item.
18
+ - **"Hide Previous" now defaults to on** so the Queue page opens focused on what's still to come.
13
19
 
14
20
  ## [0.7.0] - 2026-06-08
15
21
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.7.1
3
+ Version: 0.7.3
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -374,31 +374,6 @@ class Database:
374
374
  async def get_item_admin(self, friendly_token: str) -> dict | None:
375
375
  return await self._fetch_one("SELECT * FROM catalog WHERE friendly_token = ?", [friendly_token])
376
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
-
402
377
  async def get_catalog_brief(self, tokens: list[str], manifest_urls: list[str]) -> dict[str, dict]:
403
378
  """Return a lookup of catalog metadata keyed by BOTH friendly_token and
404
379
  manifest_url, for enriching queue-shadow items that may only carry one.
@@ -408,7 +383,7 @@ class Database:
408
383
  return {}
409
384
  placeholders = ",".join("?" * len(keys))
410
385
  rows = await self._fetch_all(
411
- "SELECT friendly_token, manifest_url, title, description, duration_sec, "
386
+ "SELECT friendly_token, manifest_url, title, duration_sec, "
412
387
  "cover_art_path, thumbnail_url FROM catalog "
413
388
  f"WHERE friendly_token IN ({placeholders}) OR manifest_url IN ({placeholders})",
414
389
  keys + keys,
@@ -422,6 +397,35 @@ class Database:
422
397
  lookup[data["manifest_url"]] = data
423
398
  return lookup
424
399
 
400
+ async def get_item_facets(self, friendly_token: str) -> dict:
401
+ """Return description + category/tag names for a single catalog item.
402
+
403
+ Used to enrich the now-playing card. Returns empty values when the
404
+ token is unknown.
405
+ """
406
+ if not friendly_token:
407
+ return {"description": None, "categories": [], "tags": []}
408
+ row = await self._fetch_one(
409
+ "SELECT description FROM catalog WHERE friendly_token = ?", [friendly_token]
410
+ )
411
+ cats = await self._fetch_all(
412
+ "SELECT cat.name FROM categories cat "
413
+ "JOIN catalog_categories cc ON cc.category_id = cat.id "
414
+ "WHERE cc.friendly_token = ? ORDER BY cat.name",
415
+ [friendly_token],
416
+ )
417
+ tags = await self._fetch_all(
418
+ "SELECT t.name FROM tags t "
419
+ "JOIN catalog_tags ct ON ct.tag_id = t.id "
420
+ "WHERE ct.friendly_token = ? ORDER BY t.name",
421
+ [friendly_token],
422
+ )
423
+ return {
424
+ "description": (row or {}).get("description"),
425
+ "categories": [c["name"] for c in cats],
426
+ "tags": [t["name"] for t in tags],
427
+ }
428
+
425
429
  async def is_restricted(self, friendly_token: str) -> bool:
426
430
  sql = """
427
431
  SELECT 1 FROM saved_playlist_items spi
@@ -201,8 +201,6 @@ 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")
206
204
  if not np.get("friendly_token"):
207
205
  np["friendly_token"] = meta.get("friendly_token")
208
206
  # Ensure now-playing carries a playlist uid so the frontend can
@@ -219,15 +217,15 @@ class QueueShadow:
219
217
  ):
220
218
  np["uid"] = it.get("uid")
221
219
  break
222
- # Attach category/tag facets for the now-playing detail panel.
223
- ft = np.get("friendly_token")
224
- if ft:
220
+ # Attach description + category/tag names for the now-playing card.
221
+ if np.get("friendly_token"):
225
222
  try:
226
- facets = await db.get_item_facets(ft)
223
+ facets = await db.get_item_facets(np["friendly_token"])
224
+ np.setdefault("description", facets.get("description"))
227
225
  np["categories"] = facets.get("categories") or []
228
226
  np["tags"] = facets.get("tags") or []
229
227
  except Exception:
230
- logger.debug("Failed to load now-playing facets", exc_info=True)
228
+ logger.debug("Failed to enrich now-playing facets", exc_info=True)
231
229
  state["now_playing"] = np
232
230
 
233
231
  return state
@@ -325,27 +325,26 @@ a:hover {
325
325
  border: 1px solid var(--accent);
326
326
  box-shadow: 0 0 0 1px rgba(108, 92, 231, 0.25), 0 6px 18px rgba(108, 92, 231, 0.18);
327
327
  }
328
- /* Title spans the full card width. */
328
+ /* Title spans the full width of the card. */
329
329
  .np-title {
330
330
  margin: 0;
331
331
  font-size: 1.4rem;
332
332
  line-height: 1.25;
333
333
  overflow-wrap: anywhere;
334
- border-bottom: 1px solid var(--border);
335
334
  padding-bottom: 0.75rem;
335
+ border-bottom: 1px solid var(--border);
336
336
  }
337
- /* Poster (2:3) on the left, playback info on the right. */
337
+ /* Image + time display sit side by side. */
338
338
  .np-body {
339
339
  display: flex;
340
340
  gap: 1.25rem;
341
- align-items: stretch;
341
+ align-items: flex-start;
342
342
  }
343
- .np-playback {
343
+ .np-info {
344
344
  flex: 1;
345
345
  min-width: 0;
346
346
  display: flex;
347
347
  flex-direction: column;
348
- justify-content: flex-start;
349
348
  gap: 0.5rem;
350
349
  }
351
350
  .np-progress {
@@ -360,43 +359,9 @@ a:hover {
360
359
  transition: width 1s linear;
361
360
  }
362
361
  .np-time {
363
- font-size: 0.95rem;
362
+ font-size: 1rem;
364
363
  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;
397
- color: var(--text-secondary);
398
- margin: 0;
399
- overflow-wrap: anywhere;
364
+ font-variant-numeric: tabular-nums;
400
365
  }
401
366
 
402
367
  .queue-list {
@@ -524,18 +489,51 @@ a:hover {
524
489
  color: var(--text-secondary);
525
490
  }
526
491
  .np-meta {
492
+ font-size: 0.85rem;
493
+ color: var(--text-secondary);
494
+ }
495
+ .np-remaining {
527
496
  font-size: 0.9rem;
528
497
  color: var(--text-secondary);
529
- margin: 0.25rem 0;
498
+ font-variant-numeric: tabular-nums;
530
499
  }
531
- .np-times {
500
+ /* Description + category/tag chips sit below the image / time row. */
501
+ .np-description {
532
502
  font-size: 0.9rem;
533
- white-space: nowrap;
503
+ line-height: 1.5;
504
+ color: var(--text-secondary);
505
+ padding-top: 0.75rem;
506
+ border-top: 1px solid var(--border);
507
+ white-space: pre-line;
508
+ overflow-wrap: anywhere;
534
509
  }
535
- .np-remaining {
536
- font-size: 0.85rem;
510
+ /* MediaCMS descriptions are newline-delimited plain text; preserve the line
511
+ breaks (Synopsis / Tagline / Cast & Crew sections) instead of collapsing them. */
512
+ .item-detail-description {
513
+ white-space: pre-line;
514
+ overflow-wrap: anywhere;
515
+ line-height: 1.6;
516
+ }
517
+ .np-chips {
518
+ display: flex;
519
+ flex-wrap: wrap;
520
+ gap: 0.4rem;
521
+ }
522
+ .np-chip {
523
+ font-size: 0.72rem;
524
+ padding: 0.2rem 0.55rem;
525
+ border-radius: 999px;
526
+ line-height: 1.3;
527
+ }
528
+ .np-chip-cat {
529
+ background: rgba(108, 92, 231, 0.22);
530
+ color: var(--text-primary);
531
+ border: 1px solid var(--accent);
532
+ }
533
+ .np-chip-tag {
534
+ background: var(--bg-elevated, #2a2a38);
537
535
  color: var(--text-secondary);
538
- margin-top: 0.1rem;
536
+ border: 1px solid var(--border);
539
537
  }
540
538
 
541
539
  /* WebSocket status */
@@ -15,7 +15,7 @@
15
15
  <div class="queue-section-header">
16
16
  <h2>Up Next</h2>
17
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)">
18
+ <input type="checkbox" id="hide-previous-toggle" onchange="toggleHidePrevious(this.checked)" checked>
19
19
  <span class="toggle-slider"></span>
20
20
  <span>Hide Previous</span>
21
21
  </label>
@@ -104,31 +104,25 @@ function renderQueue(state) {
104
104
  const pct = total > 0 ? (elapsed / total * 100) : 0;
105
105
  const cats = Array.isArray(np.categories) ? np.categories : [];
106
106
  const tags = Array.isArray(np.tags) ? np.tags : [];
107
- const facetHtml = [
107
+ const chips = [
108
108
  ...cats.map(c => `<span class="np-chip np-chip-cat">${escapeHtml(c)}</span>`),
109
109
  ...tags.map(t => `<span class="np-chip np-chip-tag">${escapeHtml(t)}</span>`),
110
110
  ].join('');
111
- const desc = (np.description || '').trim();
112
111
  npEl.innerHTML = `
113
112
  <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
113
  <div class="np-body">
116
114
  <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>
115
+ <div class="np-info">
116
+ ${np.paid_by ? `<div class="np-meta"><span class="np-user">Queued by ${escapeHtml(np.paid_by)}</span></div>` : ''}
117
+ <div class="np-time">${formatTime(elapsed)} / ${formatTime(total)}</div>
121
118
  <div class="np-progress">
122
119
  <div class="progress-bar" style="width: ${pct}%"></div>
123
120
  </div>
124
121
  <div class="np-remaining">${formatTime(remaining)} remaining</div>
125
122
  </div>
126
123
  </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>` : ''}
124
+ ${np.description ? `<div class="np-description">${escapeHtml(String(np.description).replace(/\r\n?/g, '\n'))}</div>` : ''}
125
+ ${chips ? `<div class="np-chips">${chips}</div>` : ''}
132
126
  `;
133
127
  } else {
134
128
  npEl.innerHTML = '<p class="empty-state">Nothing playing</p>';
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.7.1"
3
+ version = "0.7.3"
4
4
  description = "Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube"
5
5
  readme = "README.md"
6
6
  license = "MIT"