kryten-webqueue 0.4.5__tar.gz → 0.5.0__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.4.5 → kryten_webqueue-0.5.0}/CHANGELOG.md +23 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/PKG-INFO +1 -1
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/deploy/nginx-queue.conf +1 -1
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/api_gate/client.py +3 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/catalog/sync.py +7 -1
- kryten_webqueue-0.5.0/kryten_webqueue/queue/ordering.py +364 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/admin_queue.py +38 -1
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/queue.py +4 -2
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/catalog/browse.html +22 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/pyproject.toml +1 -1
- kryten_webqueue-0.4.5/kryten_webqueue/queue/ordering.py +0 -212
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/.gitignore +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/README.md +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/config.example.json +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/catalog/db.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/ws/manager.py +0 -0
|
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
|
|
|
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
7
|
|
|
8
|
+
## [0.5.0] - 2026-06-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Queue as Admin** — Admin users (rank ≥ 3) now see a "Queue as Admin" button on catalog cards. It queues the item at zero cost in the first available non-pay slot (top of the free section, below any paid items), treating it exactly like a non-paid item and skipping all economy interaction. New route `POST /admin/queue/add` and `insert_admin_queue()` ordering helper
|
|
13
|
+
- **Channel announcement on successful queue** — After an item is successfully queued and positioned, the channel chat receives `"<title> has been queued in position <N> by <user>"`, where position counts from the currently-playing item (position 0), so position 1 is the next item to play. Added `ApiGateClient.send_chat()`
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Wrong manifest URL stored during catalog sync** — `_build_manifest_url()` was producing the MediaCMS watch/detail page URL (`/view?m=TOKEN`, which returns HTTP 302) instead of the real CyTube manifest (`/api/v1/media/cytube/TOKEN.json?format=json`). CyTube rejected the 302 with "Expected HTTP 200 OK, not 302 Found", causing every queue attempt to fail. Existing rows are corrected on the next startup sync
|
|
18
|
+
- **queueFail now surfaced from the command response** — Instead of relying on a timeout, the Robot's `addvideo` command response now carries the CyTube `queueFail` reason. The api-gate returns it as HTTP 422 and webqueue refunds the spend and reports the actual reason. webqueue no longer needs to subscribe to the `kryten.events.cytube.channel-z.queuefail` events channel
|
|
19
|
+
- **Refund when an item cannot be positioned** — If `playlist_move` fails after a successful add, the spend is now refunded and the mis-placed item is removed, rather than leaving a paid item in the wrong slot
|
|
20
|
+
|
|
21
|
+
[0.5.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.5.0
|
|
22
|
+
|
|
23
|
+
## [0.4.6] - 2026-06-04
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **Wrong media ID sent to CyTube** — `/queue/add` and `/queue/playnext` were passing `friendly_token` (the MediaCMS slug, e.g. `"my-movie-2024"`) as the `id` field for CyTube custom media type `"cm"`. CyTube requires the manifest URL as the ID; the slug was silently rejected, the `queue` confirmation event never fired, the Robot waited 8 seconds, and kryten-py's matching 8-second timeout fired first giving a 504. Fixed by passing `item["manifest_url"]` as `media_id` to CyTube and threading `friendly_token` separately through `insert_pay_queue` / `insert_pay_playnext` for spend/history records
|
|
28
|
+
|
|
29
|
+
[0.4.6]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.4.6
|
|
30
|
+
|
|
8
31
|
## [0.4.5] - 2026-06-04
|
|
9
32
|
|
|
10
33
|
### Fixed
|
|
@@ -6,7 +6,7 @@ server {
|
|
|
6
6
|
ssl_certificate /etc/letsencrypt/live/queue.dropsugar.co/fullchain.pem;
|
|
7
7
|
ssl_certificate_key /etc/letsencrypt/live/queue.dropsugar.co/privkey.pem;
|
|
8
8
|
ssl_protocols TLSv1.2 TLSv1.3;
|
|
9
|
-
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-
|
|
9
|
+
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128it-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
|
|
10
10
|
ssl_prefer_server_ciphers off;
|
|
11
11
|
ssl_session_cache shared:SSL:10m;
|
|
12
12
|
ssl_session_timeout 1d;
|
|
@@ -73,6 +73,9 @@ class ApiGateClient:
|
|
|
73
73
|
|
|
74
74
|
# --- Chat ---
|
|
75
75
|
|
|
76
|
+
async def send_chat(self, message: str) -> dict:
|
|
77
|
+
return await self.post("/chat/send", json={"message": message})
|
|
78
|
+
|
|
76
79
|
async def send_pm(self, username: str, message: str) -> dict:
|
|
77
80
|
return await self.post("/chat/pm", json={"username": username, "message": message})
|
|
78
81
|
|
|
@@ -138,6 +138,12 @@ class CatalogSync:
|
|
|
138
138
|
await asyncio.sleep(0.25)
|
|
139
139
|
|
|
140
140
|
def _build_manifest_url(self, media: dict) -> str:
|
|
141
|
-
#
|
|
141
|
+
# CyTube custom media ("cm") requires the manifest JSON URL, NOT the
|
|
142
|
+
# human watch page (/view?m=TOKEN). MediaCMS exposes a CyTube manifest at
|
|
143
|
+
# /api/v1/media/cytube/<token>.json?format=json
|
|
144
|
+
token = media.get("friendly_token")
|
|
145
|
+
if token:
|
|
146
|
+
return f"{self._url}/api/v1/media/cytube/{token}.json?format=json"
|
|
147
|
+
# Fallback: derive token from the watch URL if friendly_token is missing
|
|
142
148
|
url = media.get("url", "")
|
|
143
149
|
return url if url.startswith("http") else f"{self._url}{url}"
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import uuid
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
# Module-level lock for queue ordering
|
|
10
|
+
_queue_lock = asyncio.Lock()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _add_failure_reason(add_result: dict | None, exc: httpx.HTTPStatusError | None) -> str:
|
|
14
|
+
"""Extract a human-readable reason from a failed playlist add."""
|
|
15
|
+
if exc is not None:
|
|
16
|
+
try:
|
|
17
|
+
detail = exc.response.json().get("detail")
|
|
18
|
+
if detail:
|
|
19
|
+
return str(detail)
|
|
20
|
+
except Exception:
|
|
21
|
+
pass
|
|
22
|
+
return f"playlist add failed ({exc.response.status_code})"
|
|
23
|
+
if add_result is not None:
|
|
24
|
+
return str(add_result.get("error", "Failed to add to playlist"))
|
|
25
|
+
return "Failed to add to playlist"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _announcement_position(shadow, uid: int) -> int | None:
|
|
29
|
+
"""Position of the item for chat announcement.
|
|
30
|
+
|
|
31
|
+
Counting starts at the currently-playing item (position 0), so the next
|
|
32
|
+
item to play is position 1. The shadow mirrors the full CyTube playlist
|
|
33
|
+
(including the active item at index 0), so the item's shadow index is the
|
|
34
|
+
announcement number.
|
|
35
|
+
"""
|
|
36
|
+
for it in shadow.items:
|
|
37
|
+
if it.get("uid") == uid:
|
|
38
|
+
return it.get("position")
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def _announce_queued(api_gate, shadow, *, uid: int, title: str, username: str) -> None:
|
|
43
|
+
"""Announce a successful queue placement to the channel chat."""
|
|
44
|
+
pos = _announcement_position(shadow, uid)
|
|
45
|
+
if pos is None:
|
|
46
|
+
return
|
|
47
|
+
try:
|
|
48
|
+
await api_gate.send_chat(f"{title} has been queued in position {pos} by {username}")
|
|
49
|
+
except Exception:
|
|
50
|
+
logger.warning("Failed to send queue announcement", exc_info=True)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def insert_pay_queue(
|
|
54
|
+
*,
|
|
55
|
+
api_gate,
|
|
56
|
+
shadow,
|
|
57
|
+
db,
|
|
58
|
+
username: str,
|
|
59
|
+
media_type: str,
|
|
60
|
+
media_id: str,
|
|
61
|
+
friendly_token: str | None = None,
|
|
62
|
+
title: str,
|
|
63
|
+
duration_sec: int,
|
|
64
|
+
tier: str,
|
|
65
|
+
z_cost: int,
|
|
66
|
+
) -> dict:
|
|
67
|
+
"""Insert a paid item at the end of the pay-queue section (FIFO)."""
|
|
68
|
+
async with _queue_lock:
|
|
69
|
+
request_id = str(uuid.uuid4())
|
|
70
|
+
|
|
71
|
+
# Spend currency (api-gate _unwrap strips the success envelope;
|
|
72
|
+
# raise_for_status propagates failures as httpx.HTTPStatusError)
|
|
73
|
+
try:
|
|
74
|
+
await api_gate.queue_spend(
|
|
75
|
+
username=username,
|
|
76
|
+
duration_sec=duration_sec,
|
|
77
|
+
tier=tier,
|
|
78
|
+
request_id=request_id,
|
|
79
|
+
)
|
|
80
|
+
except httpx.HTTPStatusError as exc:
|
|
81
|
+
return {"success": False, "error": f"Spend failed: {exc.response.status_code}"}
|
|
82
|
+
|
|
83
|
+
# Find position: after last pay item, or prepend if none
|
|
84
|
+
last_pay_uid = await db.get_last_pay_uid()
|
|
85
|
+
position = "end" if not last_pay_uid else str(last_pay_uid)
|
|
86
|
+
|
|
87
|
+
# Add to CyTube playlist
|
|
88
|
+
try:
|
|
89
|
+
add_result = await api_gate.playlist_add(
|
|
90
|
+
media_type=media_type,
|
|
91
|
+
media_id=media_id,
|
|
92
|
+
position=position,
|
|
93
|
+
)
|
|
94
|
+
except httpx.HTTPStatusError as exc:
|
|
95
|
+
try:
|
|
96
|
+
await api_gate.queue_refund(username=username, request_id=request_id, reason="playlist_add_failed")
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
return {"success": False, "error": _add_failure_reason(None, exc)}
|
|
100
|
+
if not add_result.get("success"):
|
|
101
|
+
# Refund on failure
|
|
102
|
+
try:
|
|
103
|
+
await api_gate.queue_refund(username=username, request_id=request_id, reason="add_failed")
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
return {"success": False, "error": _add_failure_reason(add_result, None)}
|
|
107
|
+
|
|
108
|
+
uid = add_result["uid"]
|
|
109
|
+
|
|
110
|
+
# Move after last pay UID if needed; refund + remove if positioning fails
|
|
111
|
+
if last_pay_uid:
|
|
112
|
+
try:
|
|
113
|
+
await api_gate.playlist_move(uid, last_pay_uid)
|
|
114
|
+
except httpx.HTTPStatusError:
|
|
115
|
+
try:
|
|
116
|
+
await api_gate.queue_refund(username=username, request_id=request_id, reason="move_failed")
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
try:
|
|
120
|
+
await api_gate.playlist_delete(uid)
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
return {"success": False, "error": "Failed to position item in queue"}
|
|
124
|
+
|
|
125
|
+
# Record spend
|
|
126
|
+
_ft = friendly_token if friendly_token is not None else (media_id if media_type == "cm" else None)
|
|
127
|
+
await db.save_spend_request(
|
|
128
|
+
request_id, username=username, uid=uid,
|
|
129
|
+
friendly_token=_ft,
|
|
130
|
+
tier=tier, z_cost=z_cost,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Update local shadow
|
|
134
|
+
item = {
|
|
135
|
+
"uid": uid,
|
|
136
|
+
"title": title,
|
|
137
|
+
"media_type": media_type,
|
|
138
|
+
"media_id": media_id,
|
|
139
|
+
"duration_sec": duration_sec,
|
|
140
|
+
"is_pay": True,
|
|
141
|
+
"paid_by": username,
|
|
142
|
+
"tier": tier,
|
|
143
|
+
"z_cost": z_cost,
|
|
144
|
+
"schedule_id": None,
|
|
145
|
+
}
|
|
146
|
+
# Position after last pay
|
|
147
|
+
if last_pay_uid:
|
|
148
|
+
pos = await db.get_shadow_position_after(last_pay_uid)
|
|
149
|
+
else:
|
|
150
|
+
pos = len(shadow.items)
|
|
151
|
+
await shadow.insert_at(item, pos)
|
|
152
|
+
|
|
153
|
+
# Queue history
|
|
154
|
+
await db.add_queue_history(
|
|
155
|
+
username=username, friendly_token=_ft,
|
|
156
|
+
title=title, tier=tier, z_cost=z_cost,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Announce placement to the channel
|
|
160
|
+
await _announce_queued(api_gate, shadow, uid=uid, title=title, username=username)
|
|
161
|
+
|
|
162
|
+
return {"success": True, "uid": uid, "request_id": request_id}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def insert_pay_playnext(
|
|
166
|
+
*,
|
|
167
|
+
api_gate,
|
|
168
|
+
shadow,
|
|
169
|
+
db,
|
|
170
|
+
username: str,
|
|
171
|
+
media_type: str,
|
|
172
|
+
media_id: str,
|
|
173
|
+
friendly_token: str | None = None,
|
|
174
|
+
title: str,
|
|
175
|
+
duration_sec: int,
|
|
176
|
+
tier: str,
|
|
177
|
+
z_cost: int,
|
|
178
|
+
) -> dict:
|
|
179
|
+
"""Insert a paid item at position 0 (play next)."""
|
|
180
|
+
async with _queue_lock:
|
|
181
|
+
request_id = str(uuid.uuid4())
|
|
182
|
+
|
|
183
|
+
# Spend currency (api-gate _unwrap strips the success envelope;
|
|
184
|
+
# raise_for_status propagates failures as httpx.HTTPStatusError)
|
|
185
|
+
try:
|
|
186
|
+
await api_gate.queue_spend(
|
|
187
|
+
username=username,
|
|
188
|
+
duration_sec=duration_sec,
|
|
189
|
+
tier=tier,
|
|
190
|
+
request_id=request_id,
|
|
191
|
+
)
|
|
192
|
+
except httpx.HTTPStatusError as exc:
|
|
193
|
+
return {"success": False, "error": f"Spend failed: {exc.response.status_code}"}
|
|
194
|
+
|
|
195
|
+
# Add to CyTube playlist at prepend position
|
|
196
|
+
try:
|
|
197
|
+
add_result = await api_gate.playlist_add(
|
|
198
|
+
media_type=media_type,
|
|
199
|
+
media_id=media_id,
|
|
200
|
+
position="end",
|
|
201
|
+
)
|
|
202
|
+
except httpx.HTTPStatusError as exc:
|
|
203
|
+
try:
|
|
204
|
+
await api_gate.queue_refund(username=username, request_id=request_id, reason="playlist_add_failed")
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
return {"success": False, "error": _add_failure_reason(None, exc)}
|
|
208
|
+
if not add_result.get("success"):
|
|
209
|
+
try:
|
|
210
|
+
await api_gate.queue_refund(username=username, request_id=request_id, reason="add_failed")
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
return {"success": False, "error": _add_failure_reason(add_result, None)}
|
|
214
|
+
|
|
215
|
+
uid = add_result["uid"]
|
|
216
|
+
|
|
217
|
+
# Move to front; refund + remove if positioning fails
|
|
218
|
+
try:
|
|
219
|
+
await api_gate.playlist_move(uid, "prepend")
|
|
220
|
+
except httpx.HTTPStatusError:
|
|
221
|
+
try:
|
|
222
|
+
await api_gate.queue_refund(username=username, request_id=request_id, reason="move_failed")
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
try:
|
|
226
|
+
await api_gate.playlist_delete(uid)
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
return {"success": False, "error": "Failed to position item in queue"}
|
|
230
|
+
|
|
231
|
+
# Record spend
|
|
232
|
+
_ft = friendly_token if friendly_token is not None else (media_id if media_type == "cm" else None)
|
|
233
|
+
await db.save_spend_request(
|
|
234
|
+
request_id, username=username, uid=uid,
|
|
235
|
+
friendly_token=_ft,
|
|
236
|
+
tier=tier, z_cost=z_cost,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Update local shadow at position 0
|
|
240
|
+
item = {
|
|
241
|
+
"uid": uid,
|
|
242
|
+
"title": title,
|
|
243
|
+
"media_type": media_type,
|
|
244
|
+
"media_id": media_id,
|
|
245
|
+
"duration_sec": duration_sec,
|
|
246
|
+
"is_pay": True,
|
|
247
|
+
"paid_by": username,
|
|
248
|
+
"tier": tier,
|
|
249
|
+
"z_cost": z_cost,
|
|
250
|
+
"schedule_id": None,
|
|
251
|
+
}
|
|
252
|
+
await shadow.insert_at(item, 0)
|
|
253
|
+
|
|
254
|
+
await db.add_queue_history(
|
|
255
|
+
username=username, friendly_token=_ft,
|
|
256
|
+
title=title, tier=tier, z_cost=z_cost,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Announce placement to the channel
|
|
260
|
+
await _announce_queued(api_gate, shadow, uid=uid, title=title, username=username)
|
|
261
|
+
|
|
262
|
+
return {"success": True, "uid": uid, "request_id": request_id}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
async def insert_admin_queue(
|
|
266
|
+
*,
|
|
267
|
+
api_gate,
|
|
268
|
+
shadow,
|
|
269
|
+
db,
|
|
270
|
+
username: str,
|
|
271
|
+
media_type: str,
|
|
272
|
+
media_id: str,
|
|
273
|
+
friendly_token: str | None = None,
|
|
274
|
+
title: str,
|
|
275
|
+
duration_sec: int,
|
|
276
|
+
) -> dict:
|
|
277
|
+
"""Insert a zero-cost admin item in the first available non-pay slot.
|
|
278
|
+
|
|
279
|
+
Treated exactly like a non-paid item (no economy interaction). It is placed
|
|
280
|
+
immediately after the last paid item, i.e. at the top of the free section.
|
|
281
|
+
"""
|
|
282
|
+
async with _queue_lock:
|
|
283
|
+
# First available non-pay slot is right after the last pay item.
|
|
284
|
+
last_pay_uid = await db.get_last_pay_uid()
|
|
285
|
+
position = "end" if not last_pay_uid else str(last_pay_uid)
|
|
286
|
+
|
|
287
|
+
# Add to CyTube playlist
|
|
288
|
+
try:
|
|
289
|
+
add_result = await api_gate.playlist_add(
|
|
290
|
+
media_type=media_type,
|
|
291
|
+
media_id=media_id,
|
|
292
|
+
position=position,
|
|
293
|
+
)
|
|
294
|
+
except httpx.HTTPStatusError as exc:
|
|
295
|
+
return {"success": False, "error": _add_failure_reason(None, exc)}
|
|
296
|
+
if not add_result.get("success"):
|
|
297
|
+
return {"success": False, "error": _add_failure_reason(add_result, None)}
|
|
298
|
+
|
|
299
|
+
uid = add_result["uid"]
|
|
300
|
+
|
|
301
|
+
# Move to the top of the free section if there are pay items above
|
|
302
|
+
if last_pay_uid:
|
|
303
|
+
try:
|
|
304
|
+
await api_gate.playlist_move(uid, last_pay_uid)
|
|
305
|
+
except httpx.HTTPStatusError:
|
|
306
|
+
try:
|
|
307
|
+
await api_gate.playlist_delete(uid)
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
return {"success": False, "error": "Failed to position item in queue"}
|
|
311
|
+
|
|
312
|
+
_ft = friendly_token if friendly_token is not None else (media_id if media_type == "cm" else None)
|
|
313
|
+
|
|
314
|
+
# Update local shadow as a non-paid item
|
|
315
|
+
item = {
|
|
316
|
+
"uid": uid,
|
|
317
|
+
"title": title,
|
|
318
|
+
"media_type": media_type,
|
|
319
|
+
"media_id": media_id,
|
|
320
|
+
"duration_sec": duration_sec,
|
|
321
|
+
"is_pay": False,
|
|
322
|
+
"paid_by": None,
|
|
323
|
+
"tier": None,
|
|
324
|
+
"z_cost": None,
|
|
325
|
+
"schedule_id": None,
|
|
326
|
+
}
|
|
327
|
+
if last_pay_uid:
|
|
328
|
+
pos = await db.get_shadow_position_after(last_pay_uid)
|
|
329
|
+
else:
|
|
330
|
+
pos = len(shadow.items)
|
|
331
|
+
await shadow.insert_at(item, pos)
|
|
332
|
+
|
|
333
|
+
# Queue history (zero cost, admin tier)
|
|
334
|
+
await db.add_queue_history(
|
|
335
|
+
username=username, friendly_token=_ft,
|
|
336
|
+
title=title, tier="admin", z_cost=0,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Announce placement to the channel
|
|
340
|
+
await _announce_queued(api_gate, shadow, uid=uid, title=title, username=username)
|
|
341
|
+
|
|
342
|
+
return {"success": True, "uid": uid}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
async def refund_item(*, api_gate, db, uid: int, reason: str) -> bool:
|
|
346
|
+
"""Refund a paid queue item."""
|
|
347
|
+
request_id = await db.get_request_id_for_uid(uid)
|
|
348
|
+
if not request_id:
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
# Look up the spend to find username
|
|
352
|
+
row = await db._fetch_one("SELECT username FROM spend_requests WHERE request_id=?", [request_id])
|
|
353
|
+
if not row:
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
result = await api_gate.queue_refund(
|
|
357
|
+
username=row["username"],
|
|
358
|
+
request_id=request_id,
|
|
359
|
+
reason=reason,
|
|
360
|
+
)
|
|
361
|
+
if result.get("success"):
|
|
362
|
+
await db.mark_spend_refunded(request_id)
|
|
363
|
+
return True
|
|
364
|
+
return False
|
|
@@ -1,11 +1,48 @@
|
|
|
1
1
|
from fastapi import APIRouter, Request, Depends, HTTPException
|
|
2
2
|
|
|
3
3
|
from ..auth.session import require_admin
|
|
4
|
-
from ..queue.ordering import refund_item
|
|
4
|
+
from ..queue.ordering import refund_item, insert_admin_queue
|
|
5
5
|
|
|
6
6
|
router = APIRouter(prefix="/admin/queue", tags=["admin"])
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
@router.post("/add")
|
|
10
|
+
async def admin_add(request: Request, user: dict = Depends(require_admin)):
|
|
11
|
+
"""Queue an item as admin: zero cost, first available non-pay slot."""
|
|
12
|
+
body = await request.json()
|
|
13
|
+
friendly_token = body.get("friendly_token")
|
|
14
|
+
if not friendly_token:
|
|
15
|
+
raise HTTPException(400, "friendly_token required")
|
|
16
|
+
|
|
17
|
+
db = request.app.state.db
|
|
18
|
+
api_gate = request.app.state.api_gate
|
|
19
|
+
shadow = request.app.state.shadow
|
|
20
|
+
|
|
21
|
+
# Check pre-fire lock
|
|
22
|
+
if await db.is_pre_fire_lock_active():
|
|
23
|
+
raise HTTPException(423, "Queue is locked: scheduled playlist firing soon")
|
|
24
|
+
|
|
25
|
+
item = await db.get_item(friendly_token)
|
|
26
|
+
if not item:
|
|
27
|
+
raise HTTPException(404, "Item not found in catalog")
|
|
28
|
+
|
|
29
|
+
result = await insert_admin_queue(
|
|
30
|
+
api_gate=api_gate,
|
|
31
|
+
shadow=shadow,
|
|
32
|
+
db=db,
|
|
33
|
+
username=user["username"],
|
|
34
|
+
media_type="cm",
|
|
35
|
+
media_id=item["manifest_url"],
|
|
36
|
+
friendly_token=friendly_token,
|
|
37
|
+
title=item["title"],
|
|
38
|
+
duration_sec=item["duration_sec"],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if not result["success"]:
|
|
42
|
+
raise HTTPException(400, result.get("error", "Admin queue add failed"))
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
9
46
|
@router.post("/clear")
|
|
10
47
|
async def clear_queue(request: Request, user: dict = Depends(require_admin)):
|
|
11
48
|
"""Clear the CyTube playlist (refunds all pay items)."""
|
|
@@ -56,7 +56,8 @@ async def add_to_queue(request: Request, user: dict = Depends(get_current_user))
|
|
|
56
56
|
db=db,
|
|
57
57
|
username=user["username"],
|
|
58
58
|
media_type="cm",
|
|
59
|
-
media_id=
|
|
59
|
+
media_id=item["manifest_url"],
|
|
60
|
+
friendly_token=friendly_token,
|
|
60
61
|
title=item["title"],
|
|
61
62
|
duration_sec=item["duration_sec"],
|
|
62
63
|
tier=tier,
|
|
@@ -111,7 +112,8 @@ async def play_next(request: Request, user: dict = Depends(get_current_user)):
|
|
|
111
112
|
db=db,
|
|
112
113
|
username=user["username"],
|
|
113
114
|
media_type="cm",
|
|
114
|
-
media_id=
|
|
115
|
+
media_id=item["manifest_url"],
|
|
116
|
+
friendly_token=friendly_token,
|
|
115
117
|
title=item["title"],
|
|
116
118
|
duration_sec=item["duration_sec"],
|
|
117
119
|
tier=tier,
|
{kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
@@ -48,6 +48,9 @@
|
|
|
48
48
|
<div class="card-actions">
|
|
49
49
|
<button class="btn btn-sm btn-queue" onclick="queueItem('{{ item.friendly_token }}')">Queue</button>
|
|
50
50
|
<button class="btn btn-sm btn-playnext" onclick="playNext('{{ item.friendly_token }}')">Play Next</button>
|
|
51
|
+
{% if user.rank >= 3 %}
|
|
52
|
+
<button class="btn btn-sm btn-admin" onclick="queueAsAdmin('{{ item.friendly_token }}')">Queue as Admin</button>
|
|
53
|
+
{% endif %}
|
|
51
54
|
</div>
|
|
52
55
|
</div>
|
|
53
56
|
{% endfor %}
|
|
@@ -126,5 +129,24 @@ async function playNext(token) {
|
|
|
126
129
|
showToast(`Network error: ${e.message}`, 'error');
|
|
127
130
|
}
|
|
128
131
|
}
|
|
132
|
+
|
|
133
|
+
async function queueAsAdmin(token) {
|
|
134
|
+
try {
|
|
135
|
+
const resp = await fetch('/admin/queue/add', {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: {'Content-Type': 'application/json'},
|
|
138
|
+
credentials: 'same-origin',
|
|
139
|
+
body: JSON.stringify({friendly_token: token})
|
|
140
|
+
});
|
|
141
|
+
const data = await resp.json();
|
|
142
|
+
if (resp.ok) {
|
|
143
|
+
showToast('Queued as admin!');
|
|
144
|
+
} else {
|
|
145
|
+
showToast(data.detail || `Failed (${resp.status})`, 'error');
|
|
146
|
+
}
|
|
147
|
+
} catch (e) {
|
|
148
|
+
showToast(`Network error: ${e.message}`, 'error');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
129
151
|
</script>
|
|
130
152
|
{% endblock %}
|
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import uuid
|
|
3
|
-
import logging
|
|
4
|
-
|
|
5
|
-
import httpx
|
|
6
|
-
|
|
7
|
-
logger = logging.getLogger(__name__)
|
|
8
|
-
|
|
9
|
-
# Module-level lock for queue ordering
|
|
10
|
-
_queue_lock = asyncio.Lock()
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
async def insert_pay_queue(
|
|
14
|
-
*,
|
|
15
|
-
api_gate,
|
|
16
|
-
shadow,
|
|
17
|
-
db,
|
|
18
|
-
username: str,
|
|
19
|
-
media_type: str,
|
|
20
|
-
media_id: str,
|
|
21
|
-
title: str,
|
|
22
|
-
duration_sec: int,
|
|
23
|
-
tier: str,
|
|
24
|
-
z_cost: int,
|
|
25
|
-
) -> dict:
|
|
26
|
-
"""Insert a paid item at the end of the pay-queue section (FIFO)."""
|
|
27
|
-
async with _queue_lock:
|
|
28
|
-
request_id = str(uuid.uuid4())
|
|
29
|
-
|
|
30
|
-
# Spend currency (api-gate _unwrap strips the success envelope;
|
|
31
|
-
# raise_for_status propagates failures as httpx.HTTPStatusError)
|
|
32
|
-
try:
|
|
33
|
-
await api_gate.queue_spend(
|
|
34
|
-
username=username,
|
|
35
|
-
duration_sec=duration_sec,
|
|
36
|
-
tier=tier,
|
|
37
|
-
request_id=request_id,
|
|
38
|
-
)
|
|
39
|
-
except httpx.HTTPStatusError as exc:
|
|
40
|
-
return {"success": False, "error": f"Spend failed: {exc.response.status_code}"}
|
|
41
|
-
|
|
42
|
-
# Find position: after last pay item, or prepend if none
|
|
43
|
-
last_pay_uid = await db.get_last_pay_uid()
|
|
44
|
-
position = "end" if not last_pay_uid else str(last_pay_uid)
|
|
45
|
-
|
|
46
|
-
# Add to CyTube playlist
|
|
47
|
-
try:
|
|
48
|
-
add_result = await api_gate.playlist_add(
|
|
49
|
-
media_type=media_type,
|
|
50
|
-
media_id=media_id,
|
|
51
|
-
position=position,
|
|
52
|
-
)
|
|
53
|
-
except httpx.HTTPStatusError:
|
|
54
|
-
try:
|
|
55
|
-
await api_gate.queue_refund(username=username, request_id=request_id, reason="playlist_add_failed")
|
|
56
|
-
except Exception:
|
|
57
|
-
pass
|
|
58
|
-
return {"success": False, "error": "Failed to add to playlist"}
|
|
59
|
-
if not add_result.get("success"):
|
|
60
|
-
# Refund on failure
|
|
61
|
-
try:
|
|
62
|
-
await api_gate.queue_refund(username=username, request_id=request_id, reason="add_failed")
|
|
63
|
-
except Exception:
|
|
64
|
-
pass
|
|
65
|
-
return {"success": False, "error": "Failed to add to playlist"}
|
|
66
|
-
|
|
67
|
-
uid = add_result["uid"]
|
|
68
|
-
|
|
69
|
-
# Move after last pay UID if needed
|
|
70
|
-
if last_pay_uid:
|
|
71
|
-
await api_gate.playlist_move(uid, last_pay_uid)
|
|
72
|
-
|
|
73
|
-
# Record spend
|
|
74
|
-
await db.save_spend_request(
|
|
75
|
-
request_id, username=username, uid=uid,
|
|
76
|
-
friendly_token=media_id if media_type == "cm" else None,
|
|
77
|
-
tier=tier, z_cost=z_cost,
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
# Update local shadow
|
|
81
|
-
item = {
|
|
82
|
-
"uid": uid,
|
|
83
|
-
"title": title,
|
|
84
|
-
"media_type": media_type,
|
|
85
|
-
"media_id": media_id,
|
|
86
|
-
"duration_sec": duration_sec,
|
|
87
|
-
"is_pay": True,
|
|
88
|
-
"paid_by": username,
|
|
89
|
-
"tier": tier,
|
|
90
|
-
"z_cost": z_cost,
|
|
91
|
-
"schedule_id": None,
|
|
92
|
-
}
|
|
93
|
-
# Position after last pay
|
|
94
|
-
if last_pay_uid:
|
|
95
|
-
pos = await db.get_shadow_position_after(last_pay_uid)
|
|
96
|
-
else:
|
|
97
|
-
pos = len(shadow.items)
|
|
98
|
-
await shadow.insert_at(item, pos)
|
|
99
|
-
|
|
100
|
-
# Queue history
|
|
101
|
-
await db.add_queue_history(
|
|
102
|
-
username=username, friendly_token=media_id if media_type == "cm" else None,
|
|
103
|
-
title=title, tier=tier, z_cost=z_cost,
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
return {"success": True, "uid": uid, "request_id": request_id}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
async def insert_pay_playnext(
|
|
110
|
-
*,
|
|
111
|
-
api_gate,
|
|
112
|
-
shadow,
|
|
113
|
-
db,
|
|
114
|
-
username: str,
|
|
115
|
-
media_type: str,
|
|
116
|
-
media_id: str,
|
|
117
|
-
title: str,
|
|
118
|
-
duration_sec: int,
|
|
119
|
-
tier: str,
|
|
120
|
-
z_cost: int,
|
|
121
|
-
) -> dict:
|
|
122
|
-
"""Insert a paid item at position 0 (play next)."""
|
|
123
|
-
async with _queue_lock:
|
|
124
|
-
request_id = str(uuid.uuid4())
|
|
125
|
-
|
|
126
|
-
# Spend currency (api-gate _unwrap strips the success envelope;
|
|
127
|
-
# raise_for_status propagates failures as httpx.HTTPStatusError)
|
|
128
|
-
try:
|
|
129
|
-
await api_gate.queue_spend(
|
|
130
|
-
username=username,
|
|
131
|
-
duration_sec=duration_sec,
|
|
132
|
-
tier=tier,
|
|
133
|
-
request_id=request_id,
|
|
134
|
-
)
|
|
135
|
-
except httpx.HTTPStatusError as exc:
|
|
136
|
-
return {"success": False, "error": f"Spend failed: {exc.response.status_code}"}
|
|
137
|
-
|
|
138
|
-
# Add to CyTube playlist at prepend position
|
|
139
|
-
try:
|
|
140
|
-
add_result = await api_gate.playlist_add(
|
|
141
|
-
media_type=media_type,
|
|
142
|
-
media_id=media_id,
|
|
143
|
-
position="end",
|
|
144
|
-
)
|
|
145
|
-
except httpx.HTTPStatusError:
|
|
146
|
-
try:
|
|
147
|
-
await api_gate.queue_refund(username=username, request_id=request_id, reason="playlist_add_failed")
|
|
148
|
-
except Exception:
|
|
149
|
-
pass
|
|
150
|
-
return {"success": False, "error": "Failed to add to playlist"}
|
|
151
|
-
if not add_result.get("success"):
|
|
152
|
-
try:
|
|
153
|
-
await api_gate.queue_refund(username=username, request_id=request_id, reason="add_failed")
|
|
154
|
-
except Exception:
|
|
155
|
-
pass
|
|
156
|
-
return {"success": False, "error": "Failed to add to playlist"}
|
|
157
|
-
|
|
158
|
-
uid = add_result["uid"]
|
|
159
|
-
|
|
160
|
-
# Move to front
|
|
161
|
-
await api_gate.playlist_move(uid, "prepend")
|
|
162
|
-
|
|
163
|
-
# Record spend
|
|
164
|
-
await db.save_spend_request(
|
|
165
|
-
request_id, username=username, uid=uid,
|
|
166
|
-
friendly_token=media_id if media_type == "cm" else None,
|
|
167
|
-
tier=tier, z_cost=z_cost,
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
# Update local shadow at position 0
|
|
171
|
-
item = {
|
|
172
|
-
"uid": uid,
|
|
173
|
-
"title": title,
|
|
174
|
-
"media_type": media_type,
|
|
175
|
-
"media_id": media_id,
|
|
176
|
-
"duration_sec": duration_sec,
|
|
177
|
-
"is_pay": True,
|
|
178
|
-
"paid_by": username,
|
|
179
|
-
"tier": tier,
|
|
180
|
-
"z_cost": z_cost,
|
|
181
|
-
"schedule_id": None,
|
|
182
|
-
}
|
|
183
|
-
await shadow.insert_at(item, 0)
|
|
184
|
-
|
|
185
|
-
await db.add_queue_history(
|
|
186
|
-
username=username, friendly_token=media_id if media_type == "cm" else None,
|
|
187
|
-
title=title, tier=tier, z_cost=z_cost,
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
return {"success": True, "uid": uid, "request_id": request_id}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
async def refund_item(*, api_gate, db, uid: int, reason: str) -> bool:
|
|
194
|
-
"""Refund a paid queue item."""
|
|
195
|
-
request_id = await db.get_request_id_for_uid(uid)
|
|
196
|
-
if not request_id:
|
|
197
|
-
return False
|
|
198
|
-
|
|
199
|
-
# Look up the spend to find username
|
|
200
|
-
row = await db._fetch_one("SELECT username FROM spend_requests WHERE request_id=?", [request_id])
|
|
201
|
-
if not row:
|
|
202
|
-
return False
|
|
203
|
-
|
|
204
|
-
result = await api_gate.queue_refund(
|
|
205
|
-
username=row["username"],
|
|
206
|
-
request_id=request_id,
|
|
207
|
-
reason=reason,
|
|
208
|
-
)
|
|
209
|
-
if result.get("success"):
|
|
210
|
-
await db.mark_spend_refunded(request_id)
|
|
211
|
-
return True
|
|
212
|
-
return False
|
|
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.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/admin/playlists.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/admin/queue_mgmt.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/admin/schedules.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.4.5 → kryten_webqueue-0.5.0}/kryten_webqueue/templates/user/dashboard.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|