Compare commits
2 Commits
9ff74550a2
...
f80fac4ce5
| Author | SHA1 | Date | |
|---|---|---|---|
| f80fac4ce5 | |||
| 61794b8db9 |
+65
-7
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
from typing import Tuple
|
||||||
from app.utils.config import server_config
|
from app.utils.config import server_config
|
||||||
from fastapi import FastAPI, Request, Form
|
from fastapi import FastAPI, Request, Form
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
@@ -6,6 +7,7 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from app.utils.plex_client import plex_client
|
from app.utils.plex_client import plex_client
|
||||||
from app.utils.logger import logger
|
from app.utils.logger import logger
|
||||||
|
from app.utils.local_playlist import scan_local_playlists
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
templates = Jinja2Templates(
|
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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def home(request: Request):
|
async def home(request: Request, local_path: str = "playlist"):
|
||||||
server_config.load()
|
server_config.load()
|
||||||
|
local_playlists = scan_local_playlists(local_path)
|
||||||
|
cloud_playlists, connection_status, server_info = _get_cloud_playlists()
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"login.html",
|
"home.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"theme": server_config.theme,
|
"theme": server_config.theme,
|
||||||
"path": "/login",
|
"path": "/",
|
||||||
"scheme": server_config.scheme,
|
"local_playlists": local_playlists,
|
||||||
"token": server_config.token,
|
"local_path": local_path,
|
||||||
"server_url": server_config.url,
|
"cloud_playlists": cloud_playlists,
|
||||||
"port": server_config.port,
|
"connection_status": connection_status,
|
||||||
|
"server_info": server_info,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -18,3 +18,23 @@
|
|||||||
.glass-toast-info {
|
.glass-toast-info {
|
||||||
background-color: rgba(var(--bs-info-rgb), 0.75) !important;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,6 +64,13 @@
|
|||||||
</a>
|
</a>
|
||||||
<hr class="d-none d-md-block">
|
<hr class="d-none d-md-block">
|
||||||
<ul class="nav nav-pills flex-column">
|
<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">
|
<li class="nav-item">
|
||||||
<a href="/login" class="nav-link {% if path == '/login' %}active{% endif %}">
|
<a href="/login" class="nav-link {% if path == '/login' %}active{% endif %}">
|
||||||
<!-- login icon -->
|
<!-- login icon -->
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
from typing import List
|
from typing import List
|
||||||
from app.utils.logger import logger
|
from app.utils.logger import logger
|
||||||
|
|
||||||
@@ -31,4 +32,37 @@ def load_local_playlist(playlist_path: str) -> List[str]:
|
|||||||
return []
|
return []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"An error occurred while loading the playlist: {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
|
||||||
Reference in New Issue
Block a user