2 Commits

Author SHA1 Message Date
Koha9 f80fac4ce5 Merge branch 'codex/add-homepage-playlist-display-feature' 2025-11-24 19:06:23 +09:00
Koha9 61794b8db9 Add playlist overview homepage 2025-11-24 17:35:42 +09:00
5 changed files with 220 additions and 8 deletions
+65 -7
View File
@@ -1,4 +1,5 @@
import os
from typing import Tuple
from app.utils.config import server_config
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
@@ -6,6 +7,7 @@ from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from app.utils.plex_client import plex_client
from app.utils.logger import logger
from app.utils.local_playlist import scan_local_playlists
app = FastAPI()
templates = Jinja2Templates(
@@ -21,20 +23,76 @@ app.mount(
)
def _get_cloud_playlists() -> Tuple[list[dict], str, dict]:
"""Fetch playlists and connection state from the remote Plex server."""
server_config.load()
playlists: list[dict] = []
status = "unset"
server_info = {
"name": "未设置",
"domain": server_config.url or "未设置",
}
# no server url configured
if not server_config.url:
return playlists, status, server_info
status = "failed"
try:
plex_client.connect(
token=server_config.token,
scheme=server_config.scheme,
url=server_config.url,
port=server_config.port,
)
status = "connected" if plex_client.connected else "failed"
server_info.update(
{
"name": getattr(plex_client.server, "friendlyName", "未命名服务器"),
"domain": server_config.url,
}
)
for playlist in plex_client.server.playlists():
track_count = getattr(playlist, "itemCount", None)
if track_count is None:
try:
track_count = len(playlist.items())
except Exception:
track_count = 0
playlists.append(
{
"name": playlist.title,
"track_count": track_count,
}
)
except Exception as exc:
logger.warning(f"Failed to fetch cloud playlists: {exc}")
status = "failed"
playlists.sort(key=lambda item: item["name"].lower())
return playlists, status, server_info
# 显示主页
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
async def home(request: Request, local_path: str = "playlist"):
server_config.load()
local_playlists = scan_local_playlists(local_path)
cloud_playlists, connection_status, server_info = _get_cloud_playlists()
return templates.TemplateResponse(
"login.html",
"home.html",
{
"request": request,
"theme": server_config.theme,
"path": "/login",
"scheme": server_config.scheme,
"token": server_config.token,
"server_url": server_config.url,
"port": server_config.port,
"path": "/",
"local_playlists": local_playlists,
"local_path": local_path,
"cloud_playlists": cloud_playlists,
"connection_status": connection_status,
"server_info": server_info,
},
)
+20
View File
@@ -18,3 +18,23 @@
.glass-toast-info {
background-color: rgba(var(--bs-info-rgb), 0.75) !important;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
border: 1px solid var(--bs-border-color);
}
.status-connected {
background-color: var(--bs-success);
}
.status-failed {
background-color: var(--bs-danger);
}
.status-unset {
background-color: var(--bs-secondary);
}
+7
View File
@@ -64,6 +64,13 @@
</a>
<hr class="d-none d-md-block">
<ul class="nav nav-pills flex-column">
<li class="nav-item">
<a href="/" class="nav-link {% if path == '/' %}active{% endif %}">
<!-- home icon -->
<i class="bi bi-house-door-fill"></i>
<span class="d-none d-md-inline ms-2">主页</span>
</a>
</li>
<li class="nav-item">
<a href="/login" class="nav-link {% if path == '/login' %}active{% endif %}">
<!-- login icon -->
+93
View File
@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}主页 - Plex Sync{% endblock %}
{% block content %}
<div class="d-flex flex-column flex-md-row align-items-md-center mb-4 gap-2">
<div>
<h1 class="h3 mb-1">播放列表概览</h1>
<p class="text-body-secondary mb-0">浏览本地与云端同步的播放列表,随时刷新最新状态。</p>
</div>
</div>
<div class="row g-4">
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<div class="d-flex flex-column flex-lg-row align-items-lg-center gap-3 mb-3">
<div>
<h5 class="card-title mb-0">本地播放列表</h5>
<small class="text-body-secondary">输入目录并刷新,读取挂载的播放列表文件</small>
</div>
<form class="ms-lg-auto w-100 w-lg-auto" method="get" action="/">
<div class="input-group">
<input type="text" class="form-control" name="local_path" value="{{ local_path }}" placeholder="playlist">
<button class="btn btn-outline-secondary" type="submit" title="刷新本地播放列表">
<i class="bi bi-arrow-clockwise"></i>
<span class="d-none d-sm-inline ms-1">刷新</span>
</button>
</div>
</form>
</div>
{% if local_playlists %}
<ul class="list-group list-group-flush">
{% for playlist in local_playlists %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{ playlist.name }}</div>
<div class="text-body-secondary small">曲目数</div>
</div>
<span class="badge rounded-pill text-bg-primary">{{ playlist.track_count }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-body-secondary">未发现播放列表,请确认目录或点击刷新。</div>
{% endif %}
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<div class="d-flex flex-column flex-lg-row align-items-lg-center gap-3 mb-3">
<div>
<h5 class="card-title mb-0">云端播放列表</h5>
<small class="text-body-secondary">来自已连接的 Plex 服务器</small>
</div>
<form class="ms-lg-auto" method="get" action="/">
<input type="hidden" name="local_path" value="{{ local_path }}">
<div class="d-flex align-items-center gap-2">
<div class="d-flex align-items-center gap-2 text-body-secondary small">
<span class="status-dot status-{{ connection_status }}"></span>
<span>
{{ server_info.name }}
<span class="text-body-secondary">· {{ server_info.domain }}</span>
</span>
</div>
<button class="btn btn-outline-secondary" type="submit" title="刷新云端播放列表">
<i class="bi bi-arrow-clockwise"></i>
<span class="d-none d-sm-inline ms-1">刷新</span>
</button>
</div>
</form>
</div>
{% if cloud_playlists %}
<ul class="list-group list-group-flush">
{% for playlist in cloud_playlists %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{ playlist.name }}</div>
<div class="text-body-secondary small">曲目数</div>
</div>
<span class="badge rounded-pill text-bg-success">{{ playlist.track_count }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-body-secondary">暂无云端播放列表,检查连接或刷新重试。</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
+35 -1
View File
@@ -1,3 +1,4 @@
import os
from typing import List
from app.utils.logger import logger
@@ -31,4 +32,37 @@ def load_local_playlist(playlist_path: str) -> List[str]:
return []
except Exception as e:
logger.error(f"An error occurred while loading the playlist: {e}")
return []
return []
def scan_local_playlists(base_path: str) -> list[dict]:
"""Scan a directory for playlist files and return their basic info.
Args:
base_path: Directory that contains playlist files.
Returns:
A list of dictionaries with ``name`` and ``track_count`` keys.
"""
playlists: list[dict] = []
if not base_path:
logger.warning("No base path provided for local playlists scan.")
return playlists
absolute_path = os.path.abspath(base_path)
if not os.path.isdir(absolute_path):
logger.warning(f"Playlist path does not exist or is not a directory: {absolute_path}")
return playlists
for entry in os.scandir(absolute_path):
if not entry.is_file():
continue
if not entry.name.lower().endswith((".m3u", ".m3u8")):
continue
tracks = load_local_playlist(entry.path)
playlists.append({"name": entry.name, "track_count": len(tracks)})
playlists.sort(key=lambda item: item["name"].lower())
logger.info(f"Found {len(playlists)} playlists under {absolute_path}.")
return playlists