2.0.0
This commit is contained in:
7
app.py
7
app.py
@@ -160,12 +160,17 @@ def create_app():
|
|||||||
def serve_config():
|
def serve_config():
|
||||||
return render_template('config.html')
|
return render_template('config.html')
|
||||||
|
|
||||||
|
# New route: Serve watch.html under /watchlist
|
||||||
|
@app.route('/watchlist')
|
||||||
|
def serve_watchlist():
|
||||||
|
return render_template('watch.html')
|
||||||
|
|
||||||
# New route: Serve playlist.html under /playlist/<id>
|
# New route: Serve playlist.html under /playlist/<id>
|
||||||
@app.route('/playlist/<id>')
|
@app.route('/playlist/<id>')
|
||||||
def serve_playlist(id):
|
def serve_playlist(id):
|
||||||
# The id parameter is captured, but you can use it as needed.
|
# The id parameter is captured, but you can use it as needed.
|
||||||
return render_template('playlist.html')
|
return render_template('playlist.html')
|
||||||
# New route: Serve playlist.html under /playlist/<id>
|
|
||||||
@app.route('/album/<id>')
|
@app.route('/album/<id>')
|
||||||
def serve_album(id):
|
def serve_album(id):
|
||||||
# The id parameter is captured, but you can use it as needed.
|
# The id parameter is captured, but you can use it as needed.
|
||||||
|
|||||||
@@ -1,47 +1,60 @@
|
|||||||
|
amqp==5.3.1
|
||||||
annotated-types==0.7.0
|
annotated-types==0.7.0
|
||||||
anyio==4.8.0
|
anyio==4.9.0
|
||||||
|
billiard==4.2.1
|
||||||
blinker==1.9.0
|
blinker==1.9.0
|
||||||
certifi==2024.12.14
|
celery==5.5.2
|
||||||
charset-normalizer==3.4.1
|
certifi==2025.4.26
|
||||||
click==8.1.8
|
charset-normalizer==3.4.2
|
||||||
deezspot @ git+https://github.com/Xoconoch/deezspot-fork-again
|
click==8.2.1
|
||||||
|
click-didyoumean==0.3.1
|
||||||
|
click-plugins==1.1.1
|
||||||
|
click-repl==0.3.0
|
||||||
|
deezspot @ git+https://github.com/Xoconoch/deezspot-fork-again@0b906e94e774cdeb8557a14a53236c0834fb1a2e
|
||||||
defusedxml==0.7.1
|
defusedxml==0.7.1
|
||||||
fastapi==0.115.7
|
fastapi==0.115.12
|
||||||
Flask==3.1.0
|
Flask==3.1.1
|
||||||
Flask-Cors==5.0.0
|
Flask-Celery-Helper==1.1.0
|
||||||
h11==0.14.0
|
flask-cors==6.0.0
|
||||||
|
h11==0.16.0
|
||||||
httptools==0.6.4
|
httptools==0.6.4
|
||||||
idna==3.10
|
idna==3.10
|
||||||
ifaddr==0.2.0
|
ifaddr==0.2.0
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
Jinja2==3.1.5
|
Jinja2==3.1.6
|
||||||
|
kombu==5.5.3
|
||||||
librespot==0.0.9
|
librespot==0.0.9
|
||||||
MarkupSafe==3.0.2
|
MarkupSafe==3.0.2
|
||||||
mutagen==1.47.0
|
mutagen==1.47.0
|
||||||
|
prompt_toolkit==3.0.51
|
||||||
protobuf==3.20.1
|
protobuf==3.20.1
|
||||||
pycryptodome==3.21.0
|
pycryptodome==3.23.0
|
||||||
pycryptodomex==3.17
|
pycryptodomex==3.17
|
||||||
pydantic==2.10.6
|
pydantic==2.11.5
|
||||||
pydantic_core==2.27.2
|
pydantic_core==2.33.2
|
||||||
PyOgg==0.6.14a1
|
PyOgg==0.6.14a1
|
||||||
python-dotenv==1.0.1
|
python-dateutil==2.9.0.post0
|
||||||
|
python-dotenv==1.1.0
|
||||||
PyYAML==6.0.2
|
PyYAML==6.0.2
|
||||||
redis==5.2.1
|
redis==6.2.0
|
||||||
requests==2.30.0
|
requests==2.30.0
|
||||||
|
six==1.17.0
|
||||||
sniffio==1.3.1
|
sniffio==1.3.1
|
||||||
spotipy
|
spotipy==2.25.1
|
||||||
spotipy_anon
|
spotipy_anon==1.4
|
||||||
starlette==0.45.3
|
starlette==0.46.2
|
||||||
tqdm==4.67.1
|
tqdm==4.67.1
|
||||||
typing_extensions==4.12.2
|
typing-inspection==0.4.1
|
||||||
urllib3==2.3.0
|
typing_extensions==4.13.2
|
||||||
uvicorn==0.34.0
|
tzdata==2025.2
|
||||||
|
urllib3==2.4.0
|
||||||
|
uvicorn==0.34.2
|
||||||
uvloop==0.21.0
|
uvloop==0.21.0
|
||||||
|
vine==5.1.0
|
||||||
waitress==3.0.2
|
waitress==3.0.2
|
||||||
watchfiles==1.0.4
|
watchfiles==1.0.5
|
||||||
|
wcwidth==0.2.13
|
||||||
websocket-client==1.5.1
|
websocket-client==1.5.1
|
||||||
websockets==14.2
|
websockets==15.0.1
|
||||||
Werkzeug==3.1.3
|
Werkzeug==3.1.3
|
||||||
zeroconf==0.62.0
|
zeroconf==0.62.0
|
||||||
celery==5.3.6
|
|
||||||
flask-celery-helper==1.1.0
|
|
||||||
@@ -71,6 +71,7 @@ def get_watch_config():
|
|||||||
CONFIG_PATH_WATCH.parent.mkdir(parents=True, exist_ok=True)
|
CONFIG_PATH_WATCH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
# Default watch config
|
# Default watch config
|
||||||
defaults = {
|
defaults = {
|
||||||
|
'enabled': False,
|
||||||
'watchedArtistAlbumGroup': ["album", "single"],
|
'watchedArtistAlbumGroup': ["album", "single"],
|
||||||
'watchPollIntervalSeconds': 3600
|
'watchPollIntervalSeconds': 3600
|
||||||
}
|
}
|
||||||
@@ -82,6 +83,7 @@ def get_watch_config():
|
|||||||
logging.error(f"Error reading watch config: {str(e)}")
|
logging.error(f"Error reading watch config: {str(e)}")
|
||||||
# Return defaults on error to prevent crashes
|
# Return defaults on error to prevent crashes
|
||||||
return {
|
return {
|
||||||
|
'enabled': False,
|
||||||
'watchedArtistAlbumGroup': ["album", "single"],
|
'watchedArtistAlbumGroup': ["album", "single"],
|
||||||
'watchPollIntervalSeconds': 3600
|
'watchPollIntervalSeconds': 3600
|
||||||
}
|
}
|
||||||
@@ -189,6 +191,7 @@ def handle_watch_config():
|
|||||||
watch_config = get_watch_config()
|
watch_config = get_watch_config()
|
||||||
# Ensure defaults are applied if file was corrupted or missing fields
|
# Ensure defaults are applied if file was corrupted or missing fields
|
||||||
defaults = {
|
defaults = {
|
||||||
|
'enabled': False,
|
||||||
'watchedArtistAlbumGroup': ["album", "single"],
|
'watchedArtistAlbumGroup': ["album", "single"],
|
||||||
'watchPollIntervalSeconds': 3600
|
'watchPollIntervalSeconds': 3600
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ CONFIG_PATH = Path('./data/config/watch.json')
|
|||||||
STOP_EVENT = threading.Event()
|
STOP_EVENT = threading.Event()
|
||||||
|
|
||||||
DEFAULT_WATCH_CONFIG = {
|
DEFAULT_WATCH_CONFIG = {
|
||||||
|
"enabled": False,
|
||||||
"watchPollIntervalSeconds": 3600,
|
"watchPollIntervalSeconds": 3600,
|
||||||
"max_tracks_per_run": 50, # For playlists
|
"max_tracks_per_run": 50, # For playlists
|
||||||
"watchedArtistAlbumGroup": ["album", "single"], # Default for artists
|
"watchedArtistAlbumGroup": ["album", "single"], # Default for artists
|
||||||
@@ -356,6 +357,12 @@ def playlist_watch_scheduler():
|
|||||||
while not STOP_EVENT.is_set():
|
while not STOP_EVENT.is_set():
|
||||||
current_config = get_watch_config() # Get latest config for this run
|
current_config = get_watch_config() # Get latest config for this run
|
||||||
interval = current_config.get("watchPollIntervalSeconds", 3600)
|
interval = current_config.get("watchPollIntervalSeconds", 3600)
|
||||||
|
watch_enabled = current_config.get("enabled", False) # Get enabled status
|
||||||
|
|
||||||
|
if not watch_enabled:
|
||||||
|
logger.info("Watch Scheduler: Watch feature is disabled in config. Skipping checks.")
|
||||||
|
STOP_EVENT.wait(interval) # Still respect poll interval for checking config again
|
||||||
|
continue # Skip to next iteration
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Watch Scheduler: Starting playlist check run.")
|
logger.info("Watch Scheduler: Starting playlist check run.")
|
||||||
|
|||||||
@@ -239,32 +239,52 @@ function setupEventListeners() {
|
|||||||
checkbox.addEventListener('change', saveWatchConfig);
|
checkbox.addEventListener('change', saveWatchConfig);
|
||||||
});
|
});
|
||||||
(document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null)?.addEventListener('change', saveWatchConfig);
|
(document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null)?.addEventListener('change', saveWatchConfig);
|
||||||
|
(document.getElementById('watchEnabledToggle') as HTMLInputElement | null)?.addEventListener('change', () => {
|
||||||
|
const isEnabling = (document.getElementById('watchEnabledToggle') as HTMLInputElement)?.checked;
|
||||||
|
const alreadyShownFirstEnableNotice = localStorage.getItem('watchFeatureFirstEnableNoticeShown');
|
||||||
|
|
||||||
|
if (isEnabling && !alreadyShownFirstEnableNotice) {
|
||||||
|
const noticeDiv = document.getElementById('watchFeatureFirstEnableNotice');
|
||||||
|
if (noticeDiv) noticeDiv.style.display = 'block';
|
||||||
|
localStorage.setItem('watchFeatureFirstEnableNoticeShown', 'true');
|
||||||
|
// Hide notice after a delay or on click if preferred
|
||||||
|
setTimeout(() => {
|
||||||
|
if (noticeDiv) noticeDiv.style.display = 'none';
|
||||||
|
}, 15000); // Hide after 15 seconds
|
||||||
|
} else {
|
||||||
|
// If disabling, or if notice was already shown, ensure it's hidden
|
||||||
|
const noticeDiv = document.getElementById('watchFeatureFirstEnableNotice');
|
||||||
|
if (noticeDiv) noticeDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
saveWatchConfig();
|
||||||
|
updateWatchWarningDisplay(); // Call this also when the watch enable toggle changes
|
||||||
|
});
|
||||||
|
(document.getElementById('realTimeToggle') as HTMLInputElement | null)?.addEventListener('change', () => {
|
||||||
|
saveConfig();
|
||||||
|
updateWatchWarningDisplay(); // Call this when realTimeToggle changes
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateServiceSpecificOptions() {
|
function updateServiceSpecificOptions() {
|
||||||
// Get the selected service
|
// Get the selected service
|
||||||
const selectedService = (document.getElementById('defaultServiceSelect') as HTMLSelectElement | null)?.value;
|
const selectedService = (document.getElementById('defaultServiceSelect') as HTMLSelectElement | null)?.value;
|
||||||
|
|
||||||
// Get all service-specific sections
|
|
||||||
const spotifyOptions = document.querySelectorAll('.config-item.spotify-specific');
|
|
||||||
const deezerOptions = document.querySelectorAll('.config-item.deezer-specific');
|
|
||||||
|
|
||||||
// Handle Spotify specific options
|
// Handle Spotify specific options
|
||||||
if (selectedService === 'spotify') {
|
if (selectedService === 'spotify') {
|
||||||
// Highlight Spotify section
|
// Highlight Spotify section
|
||||||
(document.getElementById('spotifyQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option');
|
(document.getElementById('spotifyQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option');
|
||||||
(document.getElementById('spotifyAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option');
|
(document.getElementById('spotifyAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option');
|
||||||
|
|
||||||
// Remove highlight from Deezer
|
// Remove highlight from Deezer
|
||||||
(document.getElementById('deezerQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option');
|
(document.getElementById('deezerQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option');
|
||||||
(document.getElementById('deezerAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option');
|
(document.getElementById('deezerAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option');
|
||||||
}
|
}
|
||||||
// Handle Deezer specific options (for future use)
|
// Handle Deezer specific options
|
||||||
else if (selectedService === 'deezer') {
|
else if (selectedService === 'deezer') {
|
||||||
// Highlight Deezer section
|
// Highlight Deezer section
|
||||||
(document.getElementById('deezerQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option');
|
(document.getElementById('deezerQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option');
|
||||||
(document.getElementById('deezerAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option');
|
(document.getElementById('deezerAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option');
|
||||||
|
|
||||||
// Remove highlight from Spotify
|
// Remove highlight from Spotify
|
||||||
(document.getElementById('spotifyQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option');
|
(document.getElementById('spotifyQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option');
|
||||||
(document.getElementById('spotifyAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option');
|
(document.getElementById('spotifyAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option');
|
||||||
@@ -738,10 +758,14 @@ async function handleCredentialSubmit(e: Event) {
|
|||||||
await updateAccountSelectors();
|
await updateAccountSelectors();
|
||||||
await saveConfig();
|
await saveConfig();
|
||||||
loadCredentials(service!);
|
loadCredentials(service!);
|
||||||
setFormVisibility(false); // Hide form and show add button on successful submission
|
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully');
|
showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully');
|
||||||
|
|
||||||
|
// Add a delay before hiding the form
|
||||||
|
setTimeout(() => {
|
||||||
|
setFormVisibility(false); // Hide form and show add button on successful submission
|
||||||
|
}, 2000); // 2 second delay
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
showConfigError(error.message);
|
showConfigError(error.message);
|
||||||
}
|
}
|
||||||
@@ -949,7 +973,17 @@ async function loadWatchConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const watchPollIntervalSecondsInput = document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null;
|
const watchPollIntervalSecondsInput = document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null;
|
||||||
if (watchPollIntervalSecondsInput) watchPollIntervalSecondsInput.value = watchConfig.watchPollIntervalSeconds || '3600';
|
if (watchPollIntervalSecondsInput) {
|
||||||
|
watchPollIntervalSecondsInput.value = watchConfig.watchPollIntervalSeconds || '3600';
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchEnabledToggle = document.getElementById('watchEnabledToggle') as HTMLInputElement | null;
|
||||||
|
if (watchEnabledToggle) {
|
||||||
|
watchEnabledToggle.checked = !!watchConfig.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call this after the state of the toggles has been set based on watchConfig
|
||||||
|
updateWatchWarningDisplay();
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
showConfigError('Error loading watch config: ' + error.message);
|
showConfigError('Error loading watch config: ' + error.message);
|
||||||
@@ -965,6 +999,7 @@ async function saveWatchConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const watchConfig = {
|
const watchConfig = {
|
||||||
|
enabled: (document.getElementById('watchEnabledToggle') as HTMLInputElement | null)?.checked,
|
||||||
watchedArtistAlbumGroup: selectedGroups,
|
watchedArtistAlbumGroup: selectedGroups,
|
||||||
watchPollIntervalSeconds: parseInt((document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null)?.value || '3600', 10) || 3600,
|
watchPollIntervalSeconds: parseInt((document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null)?.value || '3600', 10) || 3600,
|
||||||
};
|
};
|
||||||
@@ -985,3 +1020,27 @@ async function saveWatchConfig() {
|
|||||||
showConfigError('Error saving watch config: ' + error.message);
|
showConfigError('Error saving watch config: ' + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New function to manage the warning display
|
||||||
|
function updateWatchWarningDisplay() {
|
||||||
|
const watchEnabledToggle = document.getElementById('watchEnabledToggle') as HTMLInputElement | null;
|
||||||
|
const realTimeToggle = document.getElementById('realTimeToggle') as HTMLInputElement | null;
|
||||||
|
const warningDiv = document.getElementById('watchEnabledWarning') as HTMLElement | null;
|
||||||
|
|
||||||
|
if (watchEnabledToggle && realTimeToggle && warningDiv) {
|
||||||
|
const isWatchEnabled = watchEnabledToggle.checked;
|
||||||
|
const isRealTimeEnabled = realTimeToggle.checked;
|
||||||
|
|
||||||
|
if (isWatchEnabled && !isRealTimeEnabled) {
|
||||||
|
warningDiv.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
warningDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Hide the first-enable notice if watch is disabled or if it was already dismissed by timeout/interaction
|
||||||
|
// The primary logic for showing first-enable notice is in the event listener for watchEnabledToggle
|
||||||
|
const firstEnableNoticeDiv = document.getElementById('watchFeatureFirstEnableNotice');
|
||||||
|
if (firstEnableNoticeDiv && watchEnabledToggle && !watchEnabledToggle.checked) {
|
||||||
|
firstEnableNoticeDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
644
src/js/watch.ts
Normal file
644
src/js/watch.ts
Normal file
@@ -0,0 +1,644 @@
|
|||||||
|
import { downloadQueue } from './queue.js'; // Assuming queue.js is in the same directory
|
||||||
|
|
||||||
|
// Interfaces for API data
|
||||||
|
interface Image {
|
||||||
|
url: string;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Items from the initial /watch/list API calls ---
|
||||||
|
interface ArtistFromWatchList {
|
||||||
|
spotify_id: string; // Changed from id to spotify_id
|
||||||
|
name: string;
|
||||||
|
images?: Image[];
|
||||||
|
total_albums?: number; // Already provided by /api/artist/watch/list
|
||||||
|
}
|
||||||
|
|
||||||
|
// New interface for artists after initial processing (spotify_id mapped to id)
|
||||||
|
interface ProcessedArtistFromWatchList extends ArtistFromWatchList {
|
||||||
|
id: string; // This is the mapped spotify_id
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WatchedPlaylistOwner { // Kept as is, used by PlaylistFromWatchList
|
||||||
|
display_name?: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaylistFromWatchList {
|
||||||
|
spotify_id: string; // Changed from id to spotify_id
|
||||||
|
name: string;
|
||||||
|
owner?: WatchedPlaylistOwner;
|
||||||
|
images?: Image[]; // Ensure images can be part of this initial fetch
|
||||||
|
total_tracks?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New interface for playlists after initial processing (spotify_id mapped to id)
|
||||||
|
interface ProcessedPlaylistFromWatchList extends PlaylistFromWatchList {
|
||||||
|
id: string; // This is the mapped spotify_id
|
||||||
|
}
|
||||||
|
// --- End of /watch/list items ---
|
||||||
|
|
||||||
|
|
||||||
|
// --- Responses from /api/{artist|playlist}/info endpoints ---
|
||||||
|
interface AlbumWithImages { // For items in ArtistInfoResponse.items
|
||||||
|
images?: Image[];
|
||||||
|
// Other album properties like name, id etc., are not strictly needed for this specific change
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArtistInfoResponse {
|
||||||
|
artist_id: string; // Matches key from artist.py
|
||||||
|
artist_name: string; // Matches key from artist.py
|
||||||
|
artist_image_url?: string; // Matches key from artist.py
|
||||||
|
total: number; // This is total_albums, matches key from artist.py
|
||||||
|
artist_external_url?: string; // Matches key from artist.py
|
||||||
|
items?: AlbumWithImages[]; // Add album items to get the first album's image
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaylistInfoResponse is effectively the Playlist interface from playlist.ts
|
||||||
|
// For clarity, defining it here based on what's needed for the card.
|
||||||
|
interface PlaylistInfoResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
owner: { display_name?: string; id?: string; }; // Matches Playlist.owner
|
||||||
|
images: Image[]; // Matches Playlist.images
|
||||||
|
tracks: { total: number; /* items: PlaylistItem[] - not needed for card */ }; // Matches Playlist.tracks
|
||||||
|
followers?: { total: number; }; // Matches Playlist.followers
|
||||||
|
external_urls?: { spotify?: string }; // Matches Playlist.external_urls
|
||||||
|
}
|
||||||
|
// --- End of /info endpoint responses ---
|
||||||
|
|
||||||
|
|
||||||
|
// --- Final combined data structure for rendering cards ---
|
||||||
|
interface FinalArtistCardItem {
|
||||||
|
itemType: 'artist';
|
||||||
|
id: string; // Spotify ID
|
||||||
|
name: string; // Best available name (from /info or fallback)
|
||||||
|
imageUrl?: string; // Best available image URL (from /info or fallback)
|
||||||
|
total_albums: number;// From /info or fallback
|
||||||
|
external_urls?: { spotify?: string }; // From /info
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FinalPlaylistCardItem {
|
||||||
|
itemType: 'playlist';
|
||||||
|
id: string; // Spotify ID
|
||||||
|
name: string; // Best available name (from /info or fallback)
|
||||||
|
imageUrl?: string; // Best available image URL (from /info or fallback)
|
||||||
|
owner_name?: string; // From /info or fallback
|
||||||
|
total_tracks: number;// From /info or fallback
|
||||||
|
followers_count?: number; // From /info
|
||||||
|
description?: string | null; // From /info, for potential use (e.g., tooltip)
|
||||||
|
external_urls?: { spotify?: string }; // From /info
|
||||||
|
}
|
||||||
|
|
||||||
|
type FinalCardItem = FinalArtistCardItem | FinalPlaylistCardItem;
|
||||||
|
// --- End of final card data structure ---
|
||||||
|
|
||||||
|
// The type for items initially fetched from /watch/list, before detailed processing
|
||||||
|
// Updated to use ProcessedArtistFromWatchList for artists and ProcessedPlaylistFromWatchList for playlists
|
||||||
|
type InitialWatchedItem =
|
||||||
|
(ProcessedArtistFromWatchList & { itemType: 'artist' }) |
|
||||||
|
(ProcessedPlaylistFromWatchList & { itemType: 'playlist' });
|
||||||
|
|
||||||
|
// Interface for a settled promise (fulfilled)
|
||||||
|
interface CustomPromiseFulfilledResult<T> {
|
||||||
|
status: 'fulfilled';
|
||||||
|
value: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface for a settled promise (rejected)
|
||||||
|
interface CustomPromiseRejectedResult {
|
||||||
|
status: 'rejected';
|
||||||
|
reason: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomSettledPromiseResult<T> = CustomPromiseFulfilledResult<T> | CustomPromiseRejectedResult;
|
||||||
|
|
||||||
|
// Original WatchedItem type, which will be replaced by FinalCardItem for rendering
|
||||||
|
interface WatchedArtistOriginal {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
images?: Image[];
|
||||||
|
total_albums?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WatchedPlaylistOriginal {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
owner?: WatchedPlaylistOwner;
|
||||||
|
images?: Image[];
|
||||||
|
total_tracks?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WatchedItem = (WatchedArtistOriginal & { itemType: 'artist' }) | (WatchedPlaylistOriginal & { itemType: 'playlist' });
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const watchedItemsContainer = document.getElementById('watchedItemsContainer');
|
||||||
|
const loadingIndicator = document.getElementById('loadingWatchedItems');
|
||||||
|
const emptyStateIndicator = document.getElementById('emptyWatchedItems');
|
||||||
|
const queueIcon = document.getElementById('queueIcon');
|
||||||
|
const checkAllWatchedBtn = document.getElementById('checkAllWatchedBtn') as HTMLButtonElement | null;
|
||||||
|
|
||||||
|
if (queueIcon) {
|
||||||
|
queueIcon.addEventListener('click', () => {
|
||||||
|
downloadQueue.toggleVisibility();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkAllWatchedBtn) {
|
||||||
|
checkAllWatchedBtn.addEventListener('click', async () => {
|
||||||
|
checkAllWatchedBtn.disabled = true;
|
||||||
|
const originalText = checkAllWatchedBtn.innerHTML;
|
||||||
|
checkAllWatchedBtn.innerHTML = '<img src="/static/images/refresh-cw.svg" alt="Refreshing..."> Checking...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const artistCheckPromise = fetch('/api/artist/watch/trigger_check', { method: 'POST' });
|
||||||
|
const playlistCheckPromise = fetch('/api/playlist/watch/trigger_check', { method: 'POST' });
|
||||||
|
|
||||||
|
// Use Promise.allSettled-like behavior to handle both responses
|
||||||
|
const results = await Promise.all([
|
||||||
|
artistCheckPromise.then(async res => ({
|
||||||
|
ok: res.ok,
|
||||||
|
data: await res.json().catch(() => ({ error: 'Invalid JSON response' })),
|
||||||
|
type: 'artist'
|
||||||
|
})).catch(e => ({ ok: false, data: { error: e.message || 'Request failed' }, type: 'artist' })),
|
||||||
|
playlistCheckPromise.then(async res => ({
|
||||||
|
ok: res.ok,
|
||||||
|
data: await res.json().catch(() => ({ error: 'Invalid JSON response' })),
|
||||||
|
type: 'playlist'
|
||||||
|
})).catch(e => ({ ok: false, data: { error: e.message || 'Request failed' }, type: 'playlist' }))
|
||||||
|
]);
|
||||||
|
|
||||||
|
const artistResult = results.find(r => r.type === 'artist');
|
||||||
|
const playlistResult = results.find(r => r.type === 'playlist');
|
||||||
|
|
||||||
|
let successMessages: string[] = [];
|
||||||
|
let errorMessages: string[] = [];
|
||||||
|
|
||||||
|
if (artistResult) {
|
||||||
|
if (artistResult.ok) {
|
||||||
|
successMessages.push(artistResult.data.message || 'Artist check triggered.');
|
||||||
|
} else {
|
||||||
|
errorMessages.push(`Artist check failed: ${artistResult.data.error || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playlistResult) {
|
||||||
|
if (playlistResult.ok) {
|
||||||
|
successMessages.push(playlistResult.data.message || 'Playlist check triggered.');
|
||||||
|
} else {
|
||||||
|
errorMessages.push(`Playlist check failed: ${playlistResult.data.error || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessages.length > 0) {
|
||||||
|
showNotification(errorMessages.join(' '), true);
|
||||||
|
if (successMessages.length > 0) { // If some succeeded and some failed
|
||||||
|
// Delay the success message slightly so it doesn't overlap or get missed
|
||||||
|
setTimeout(() => showNotification(successMessages.join(' ')), 1000);
|
||||||
|
}
|
||||||
|
} else if (successMessages.length > 0) {
|
||||||
|
showNotification(successMessages.join(' '));
|
||||||
|
} else {
|
||||||
|
showNotification('Could not determine check status for artists or playlists.', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) { // Catch for unexpected issues with Promise.all or setup
|
||||||
|
console.error('Error in checkAllWatchedBtn handler:', error);
|
||||||
|
showNotification(`An unexpected error occurred: ${error.message}`, true);
|
||||||
|
} finally {
|
||||||
|
checkAllWatchedBtn.disabled = false;
|
||||||
|
checkAllWatchedBtn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadWatchedItems();
|
||||||
|
});
|
||||||
|
|
||||||
|
const MAX_NOTIFICATIONS = 3;
|
||||||
|
|
||||||
|
async function loadWatchedItems() {
|
||||||
|
const watchedItemsContainer = document.getElementById('watchedItemsContainer');
|
||||||
|
const loadingIndicator = document.getElementById('loadingWatchedItems');
|
||||||
|
const emptyStateIndicator = document.getElementById('emptyWatchedItems');
|
||||||
|
|
||||||
|
showLoading(true);
|
||||||
|
showEmptyState(false);
|
||||||
|
if (watchedItemsContainer) watchedItemsContainer.innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [artistsResponse, playlistsResponse] = await Promise.all([
|
||||||
|
fetch('/api/artist/watch/list'),
|
||||||
|
fetch('/api/playlist/watch/list')
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!artistsResponse.ok || !playlistsResponse.ok) {
|
||||||
|
throw new Error('Failed to load initial watched items list');
|
||||||
|
}
|
||||||
|
|
||||||
|
const artists: ArtistFromWatchList[] = await artistsResponse.json();
|
||||||
|
const playlists: PlaylistFromWatchList[] = await playlistsResponse.json();
|
||||||
|
|
||||||
|
const initialItems: InitialWatchedItem[] = [
|
||||||
|
...artists.map(artist => ({
|
||||||
|
...artist,
|
||||||
|
id: artist.spotify_id, // Map spotify_id to id for artists
|
||||||
|
itemType: 'artist' as const
|
||||||
|
})),
|
||||||
|
...playlists.map(playlist => ({
|
||||||
|
...playlist,
|
||||||
|
id: playlist.spotify_id, // Map spotify_id to id for playlists
|
||||||
|
itemType: 'playlist' as const
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
if (initialItems.length === 0) {
|
||||||
|
showLoading(false);
|
||||||
|
showEmptyState(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch detailed info for each item
|
||||||
|
const detailedItemPromises = initialItems.map(async (initialItem) => {
|
||||||
|
try {
|
||||||
|
if (initialItem.itemType === 'artist') {
|
||||||
|
const infoResponse = await fetch(`/api/artist/info?id=${initialItem.id}`);
|
||||||
|
if (!infoResponse.ok) {
|
||||||
|
console.warn(`Failed to fetch artist info for ${initialItem.name} (ID: ${initialItem.id}): ${infoResponse.status}`);
|
||||||
|
// Fallback to initial data if info fetch fails
|
||||||
|
return {
|
||||||
|
itemType: 'artist',
|
||||||
|
id: initialItem.id,
|
||||||
|
name: initialItem.name,
|
||||||
|
imageUrl: (initialItem as ArtistFromWatchList).images?.[0]?.url, // Cast to access images
|
||||||
|
total_albums: (initialItem as ArtistFromWatchList).total_albums || 0, // Cast to access total_albums
|
||||||
|
} as FinalArtistCardItem;
|
||||||
|
}
|
||||||
|
const info: ArtistInfoResponse = await infoResponse.json();
|
||||||
|
return {
|
||||||
|
itemType: 'artist',
|
||||||
|
id: initialItem.id, // Use the ID from the watch list, as /info might have 'artist_id'
|
||||||
|
name: info.artist_name || initialItem.name, // Prefer info, fallback to initial
|
||||||
|
imageUrl: info.items?.[0]?.images?.[0]?.url || info.artist_image_url || (initialItem as ProcessedArtistFromWatchList).images?.[0]?.url, // Prioritize first album image from items
|
||||||
|
total_albums: info.total, // 'total' from ArtistInfoResponse is total_albums
|
||||||
|
external_urls: { spotify: info.artist_external_url }
|
||||||
|
} as FinalArtistCardItem;
|
||||||
|
} else { // Playlist
|
||||||
|
const infoResponse = await fetch(`/api/playlist/info?id=${initialItem.id}`);
|
||||||
|
if (!infoResponse.ok) {
|
||||||
|
console.warn(`Failed to fetch playlist info for ${initialItem.name} (ID: ${initialItem.id}): ${infoResponse.status}`);
|
||||||
|
// Fallback to initial data if info fetch fails
|
||||||
|
return {
|
||||||
|
itemType: 'playlist',
|
||||||
|
id: initialItem.id,
|
||||||
|
name: initialItem.name,
|
||||||
|
imageUrl: (initialItem as ProcessedPlaylistFromWatchList).images?.[0]?.url, // Cast to access images
|
||||||
|
owner_name: (initialItem as ProcessedPlaylistFromWatchList).owner?.display_name, // Cast to access owner
|
||||||
|
total_tracks: (initialItem as ProcessedPlaylistFromWatchList).total_tracks || 0, // Cast to access total_tracks
|
||||||
|
} as FinalPlaylistCardItem;
|
||||||
|
}
|
||||||
|
const info: PlaylistInfoResponse = await infoResponse.json();
|
||||||
|
return {
|
||||||
|
itemType: 'playlist',
|
||||||
|
id: initialItem.id, // Use ID from watch list
|
||||||
|
name: info.name || initialItem.name, // Prefer info, fallback to initial
|
||||||
|
imageUrl: info.images?.[0]?.url || (initialItem as ProcessedPlaylistFromWatchList).images?.[0]?.url, // Prefer info, fallback to initial (ProcessedPlaylistFromWatchList)
|
||||||
|
owner_name: info.owner?.display_name || (initialItem as ProcessedPlaylistFromWatchList).owner?.display_name, // Prefer info, fallback to initial (ProcessedPlaylistFromWatchList)
|
||||||
|
total_tracks: info.tracks.total, // 'total' from PlaylistInfoResponse.tracks
|
||||||
|
followers_count: info.followers?.total,
|
||||||
|
description: info.description,
|
||||||
|
external_urls: info.external_urls
|
||||||
|
} as FinalPlaylistCardItem;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(`Error processing item ${initialItem.name} (ID: ${initialItem.id}):`, e);
|
||||||
|
// Return a fallback structure if processing fails catastrophically
|
||||||
|
return {
|
||||||
|
itemType: initialItem.itemType,
|
||||||
|
id: initialItem.id,
|
||||||
|
name: initialItem.name + " (Error loading details)",
|
||||||
|
imageUrl: initialItem.images?.[0]?.url,
|
||||||
|
// Add minimal common fields for artists and playlists for fallback
|
||||||
|
...(initialItem.itemType === 'artist' ? { total_albums: (initialItem as ProcessedArtistFromWatchList).total_albums || 0 } : {}),
|
||||||
|
...(initialItem.itemType === 'playlist' ? { total_tracks: (initialItem as ProcessedPlaylistFromWatchList).total_tracks || 0 } : {}),
|
||||||
|
} as FinalCardItem; // Cast to avoid TS errors, knowing one of the spreads will match
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulating Promise.allSettled behavior for compatibility
|
||||||
|
const settledResults: CustomSettledPromiseResult<FinalCardItem>[] = await Promise.all(
|
||||||
|
detailedItemPromises.map(p =>
|
||||||
|
p.then(value => ({ status: 'fulfilled', value } as CustomPromiseFulfilledResult<FinalCardItem>))
|
||||||
|
.catch(reason => ({ status: 'rejected', reason } as CustomPromiseRejectedResult))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalItems: FinalCardItem[] = settledResults
|
||||||
|
.filter((result): result is CustomPromiseFulfilledResult<FinalCardItem> => result.status === 'fulfilled')
|
||||||
|
.map(result => result.value)
|
||||||
|
.filter(item => item !== null) as FinalCardItem[]; // Ensure no nulls from catastrophic failures
|
||||||
|
|
||||||
|
showLoading(false);
|
||||||
|
|
||||||
|
if (finalItems.length === 0) {
|
||||||
|
showEmptyState(true);
|
||||||
|
// Potentially show a different message if initialItems existed but all failed to load details
|
||||||
|
if (initialItems.length > 0 && watchedItemsContainer) {
|
||||||
|
watchedItemsContainer.innerHTML = `<div class="error"><p>Could not load details for any watched items. Please check the console for errors.</p></div>`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (watchedItemsContainer) {
|
||||||
|
// Clear previous content
|
||||||
|
watchedItemsContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (finalItems.length > 8) {
|
||||||
|
const playlistItems = finalItems.filter(item => item.itemType === 'playlist') as FinalPlaylistCardItem[];
|
||||||
|
const artistItems = finalItems.filter(item => item.itemType === 'artist') as FinalArtistCardItem[];
|
||||||
|
|
||||||
|
// Create and append Playlist section
|
||||||
|
if (playlistItems.length > 0) {
|
||||||
|
const playlistSection = document.createElement('div');
|
||||||
|
playlistSection.className = 'watched-items-group';
|
||||||
|
const playlistHeader = document.createElement('h2');
|
||||||
|
playlistHeader.className = 'watched-group-header';
|
||||||
|
playlistHeader.textContent = 'Watched Playlists';
|
||||||
|
playlistSection.appendChild(playlistHeader);
|
||||||
|
const playlistGrid = document.createElement('div');
|
||||||
|
playlistGrid.className = 'results-grid'; // Use existing grid style
|
||||||
|
playlistItems.forEach(item => {
|
||||||
|
const cardElement = createWatchedItemCard(item);
|
||||||
|
playlistGrid.appendChild(cardElement);
|
||||||
|
});
|
||||||
|
playlistSection.appendChild(playlistGrid);
|
||||||
|
watchedItemsContainer.appendChild(playlistSection);
|
||||||
|
} else {
|
||||||
|
const noPlaylistsMessage = document.createElement('p');
|
||||||
|
noPlaylistsMessage.textContent = 'No watched playlists.';
|
||||||
|
noPlaylistsMessage.className = 'empty-group-message';
|
||||||
|
// Optionally add a header for consistency even if empty
|
||||||
|
const playlistHeader = document.createElement('h2');
|
||||||
|
playlistHeader.className = 'watched-group-header';
|
||||||
|
playlistHeader.textContent = 'Watched Playlists';
|
||||||
|
watchedItemsContainer.appendChild(playlistHeader);
|
||||||
|
watchedItemsContainer.appendChild(noPlaylistsMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and append Artist section
|
||||||
|
if (artistItems.length > 0) {
|
||||||
|
const artistSection = document.createElement('div');
|
||||||
|
artistSection.className = 'watched-items-group';
|
||||||
|
const artistHeader = document.createElement('h2');
|
||||||
|
artistHeader.className = 'watched-group-header';
|
||||||
|
artistHeader.textContent = 'Watched Artists';
|
||||||
|
artistSection.appendChild(artistHeader);
|
||||||
|
const artistGrid = document.createElement('div');
|
||||||
|
artistGrid.className = 'results-grid'; // Use existing grid style
|
||||||
|
artistItems.forEach(item => {
|
||||||
|
const cardElement = createWatchedItemCard(item);
|
||||||
|
artistGrid.appendChild(cardElement);
|
||||||
|
});
|
||||||
|
artistSection.appendChild(artistGrid);
|
||||||
|
watchedItemsContainer.appendChild(artistSection);
|
||||||
|
} else {
|
||||||
|
const noArtistsMessage = document.createElement('p');
|
||||||
|
noArtistsMessage.textContent = 'No watched artists.';
|
||||||
|
noArtistsMessage.className = 'empty-group-message';
|
||||||
|
// Optionally add a header for consistency even if empty
|
||||||
|
const artistHeader = document.createElement('h2');
|
||||||
|
artistHeader.className = 'watched-group-header';
|
||||||
|
artistHeader.textContent = 'Watched Artists';
|
||||||
|
watchedItemsContainer.appendChild(artistHeader);
|
||||||
|
watchedItemsContainer.appendChild(noArtistsMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else { // 8 or fewer items, render them directly
|
||||||
|
finalItems.forEach(item => {
|
||||||
|
const cardElement = createWatchedItemCard(item);
|
||||||
|
watchedItemsContainer.appendChild(cardElement);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error loading watched items:', error);
|
||||||
|
showLoading(false);
|
||||||
|
if (watchedItemsContainer) {
|
||||||
|
watchedItemsContainer.innerHTML = `<div class="error"><p>Error loading watched items: ${error.message}</p></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWatchedItemCard(item: FinalCardItem): HTMLDivElement {
|
||||||
|
const cardElement = document.createElement('div');
|
||||||
|
cardElement.className = 'watched-item-card';
|
||||||
|
cardElement.dataset.itemId = item.id;
|
||||||
|
cardElement.dataset.itemType = item.itemType;
|
||||||
|
|
||||||
|
// Check Now button HTML is no longer generated separately here for absolute positioning
|
||||||
|
|
||||||
|
let imageUrl = '/static/images/placeholder.jpg';
|
||||||
|
if (item.imageUrl) {
|
||||||
|
imageUrl = item.imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
let detailsHtml = '';
|
||||||
|
let typeBadgeClass = '';
|
||||||
|
let typeName = '';
|
||||||
|
|
||||||
|
if (item.itemType === 'artist') {
|
||||||
|
typeName = 'Artist';
|
||||||
|
typeBadgeClass = 'artist';
|
||||||
|
const artist = item as FinalArtistCardItem;
|
||||||
|
detailsHtml = artist.total_albums !== undefined ? `<span>${artist.total_albums} albums</span>` : '';
|
||||||
|
} else if (item.itemType === 'playlist') {
|
||||||
|
typeName = 'Playlist';
|
||||||
|
typeBadgeClass = 'playlist';
|
||||||
|
const playlist = item as FinalPlaylistCardItem;
|
||||||
|
detailsHtml = playlist.owner_name ? `<span>By: ${playlist.owner_name}</span>` : '';
|
||||||
|
detailsHtml += playlist.total_tracks !== undefined ? `<span> • ${playlist.total_tracks} tracks</span>` : '';
|
||||||
|
if (playlist.followers_count !== undefined) {
|
||||||
|
detailsHtml += `<span> • ${playlist.followers_count} followers</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cardElement.innerHTML = `
|
||||||
|
<div class="item-art-wrapper">
|
||||||
|
<img class="item-art" src="${imageUrl}" alt="${item.name}" onerror="handleImageError(this)">
|
||||||
|
</div>
|
||||||
|
<div class="item-name">${item.name}</div>
|
||||||
|
<div class="item-details">${detailsHtml}</div>
|
||||||
|
<span class="item-type-badge ${typeBadgeClass}">${typeName}</span>
|
||||||
|
<div class="item-actions">
|
||||||
|
<button class="btn-icon unwatch-item-btn" data-id="${item.id}" data-type="${item.itemType}" title="Unwatch">
|
||||||
|
<img src="/static/images/eye-crossed.svg" alt="Unwatch">
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon check-item-now-btn" data-id="${item.id}" data-type="${item.itemType}" title="Check Now">
|
||||||
|
<img src="/static/images/refresh.svg" alt="Check">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add click event to navigate to the item's detail page
|
||||||
|
cardElement.addEventListener('click', (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
// Don't navigate if any button within the card was clicked
|
||||||
|
if (target.closest('button')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = `/${item.itemType}/${item.id}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add event listener for the "Check Now" button
|
||||||
|
const checkNowBtn = cardElement.querySelector('.check-item-now-btn') as HTMLButtonElement | null;
|
||||||
|
if (checkNowBtn) {
|
||||||
|
checkNowBtn.addEventListener('click', (e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const itemId = checkNowBtn.dataset.id;
|
||||||
|
const itemType = checkNowBtn.dataset.type as 'artist' | 'playlist';
|
||||||
|
if (itemId && itemType) {
|
||||||
|
triggerItemCheck(itemId, itemType, checkNowBtn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for the "Unwatch" button
|
||||||
|
const unwatchBtn = cardElement.querySelector('.unwatch-item-btn') as HTMLButtonElement | null;
|
||||||
|
if (unwatchBtn) {
|
||||||
|
unwatchBtn.addEventListener('click', (e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const itemId = unwatchBtn.dataset.id;
|
||||||
|
const itemType = unwatchBtn.dataset.type as 'artist' | 'playlist';
|
||||||
|
if (itemId && itemType) {
|
||||||
|
unwatchItem(itemId, itemType, unwatchBtn, cardElement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cardElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoading(show: boolean) {
|
||||||
|
const loadingIndicator = document.getElementById('loadingWatchedItems');
|
||||||
|
if (loadingIndicator) loadingIndicator.classList.toggle('hidden', !show);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEmptyState(show: boolean) {
|
||||||
|
const emptyStateIndicator = document.getElementById('emptyWatchedItems');
|
||||||
|
if (emptyStateIndicator) emptyStateIndicator.classList.toggle('hidden', !show);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unwatchItem(itemId: string, itemType: 'artist' | 'playlist', buttonElement: HTMLButtonElement, cardElement: HTMLElement) {
|
||||||
|
const originalButtonContent = buttonElement.innerHTML;
|
||||||
|
buttonElement.disabled = true;
|
||||||
|
buttonElement.innerHTML = '<img src="/static/images/loader-small.svg" alt="Unwatching...">'; // Assuming a small loader icon
|
||||||
|
|
||||||
|
const endpoint = `/api/${itemType}/watch/${itemId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, { method: 'DELETE' });
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || `Server error: ${response.status}`);
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
showNotification(result.message || `${itemType.charAt(0).toUpperCase() + itemType.slice(1)} unwatched successfully.`);
|
||||||
|
|
||||||
|
cardElement.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
|
||||||
|
cardElement.style.opacity = '0';
|
||||||
|
cardElement.style.transform = 'scale(0.9)';
|
||||||
|
setTimeout(() => {
|
||||||
|
cardElement.remove();
|
||||||
|
const watchedItemsContainer = document.getElementById('watchedItemsContainer');
|
||||||
|
const playlistGroups = document.querySelectorAll('.watched-items-group .results-grid');
|
||||||
|
let totalItemsLeft = 0;
|
||||||
|
|
||||||
|
if (playlistGroups.length > 0) { // Grouped view
|
||||||
|
playlistGroups.forEach(group => {
|
||||||
|
totalItemsLeft += group.childElementCount;
|
||||||
|
});
|
||||||
|
// If a group becomes empty, we might want to remove the group header or show an empty message for that group.
|
||||||
|
// This can be added here if desired.
|
||||||
|
} else if (watchedItemsContainer) { // Non-grouped view
|
||||||
|
totalItemsLeft = watchedItemsContainer.childElementCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalItemsLeft === 0) {
|
||||||
|
// If all items are gone (either from groups or directly), reload to show empty state.
|
||||||
|
// This also correctly handles the case where the initial list had <= 8 items.
|
||||||
|
loadWatchedItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error unwatching ${itemType}:`, error);
|
||||||
|
showNotification(`Failed to unwatch: ${error.message}`, true);
|
||||||
|
buttonElement.disabled = false;
|
||||||
|
buttonElement.innerHTML = originalButtonContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerItemCheck(itemId: string, itemType: 'artist' | 'playlist', buttonElement: HTMLButtonElement) {
|
||||||
|
const originalButtonContent = buttonElement.innerHTML; // Will just be the img
|
||||||
|
buttonElement.disabled = true;
|
||||||
|
// Keep the icon, but we can add a class for spinning or use the same icon.
|
||||||
|
// For simplicity, just using the same icon. Text "Checking..." is removed.
|
||||||
|
buttonElement.innerHTML = '<img src="/static/images/refresh.svg" alt="Checking...">';
|
||||||
|
|
||||||
|
const endpoint = `/api/${itemType}/watch/trigger_check/${itemId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, { method: 'POST' });
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({})); // Handle non-JSON error responses
|
||||||
|
throw new Error(errorData.error || `Server error: ${response.status}`);
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
showNotification(result.message || `Successfully triggered check for ${itemType}.`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error triggering ${itemType} check:`, error);
|
||||||
|
showNotification(`Failed to trigger check: ${error.message}`, true);
|
||||||
|
} finally {
|
||||||
|
buttonElement.disabled = false;
|
||||||
|
buttonElement.innerHTML = originalButtonContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to show notifications (can be moved to a shared utility file if used elsewhere)
|
||||||
|
function showNotification(message: string, isError: boolean = false) {
|
||||||
|
const notificationArea = document.getElementById('notificationArea') || createNotificationArea();
|
||||||
|
|
||||||
|
// Limit the number of visible notifications
|
||||||
|
while (notificationArea.childElementCount >= MAX_NOTIFICATIONS) {
|
||||||
|
const oldestNotification = notificationArea.firstChild; // In column-reverse, firstChild is visually the bottom one
|
||||||
|
if (oldestNotification) {
|
||||||
|
oldestNotification.remove();
|
||||||
|
} else {
|
||||||
|
break; // Should not happen if childElementCount > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `notification-toast ${isError ? 'error' : 'success'}`;
|
||||||
|
notification.textContent = message;
|
||||||
|
|
||||||
|
notificationArea.appendChild(notification);
|
||||||
|
|
||||||
|
// Auto-remove after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.add('hide');
|
||||||
|
setTimeout(() => notification.remove(), 500); // Remove from DOM after fade out
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNotificationArea(): HTMLElement {
|
||||||
|
const area = document.createElement('div');
|
||||||
|
area.id = 'notificationArea';
|
||||||
|
document.body.appendChild(area);
|
||||||
|
return area;
|
||||||
|
}
|
||||||
@@ -985,4 +985,42 @@ input:checked + .slider:before {
|
|||||||
/* Reset some global label styles if they interfere */
|
/* Reset some global label styles if they interfere */
|
||||||
display: inline;
|
display: inline;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Urgent Warning Message Style */
|
||||||
|
.urgent-warning-message {
|
||||||
|
background-color: rgba(255, 165, 0, 0.1); /* Orange/Amber background */
|
||||||
|
border: 1px solid #FFA500; /* Orange/Amber border */
|
||||||
|
color: #FFA500; /* Orange/Amber text */
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex; /* Use flex to align icon and text */
|
||||||
|
align-items: center; /* Vertically align icon and text */
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-warning-message .warning-icon {
|
||||||
|
margin-right: 0.75rem; /* Space between icon and text */
|
||||||
|
min-width: 24px; /* Ensure icon doesn't shrink too much */
|
||||||
|
color: #FFA500; /* Match icon color to text/border */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Existing info-message style - ensure it doesn't conflict or adjust if needed */
|
||||||
|
.info-message {
|
||||||
|
background-color: rgba(0, 123, 255, 0.1);
|
||||||
|
border: 1px solid #007bff;
|
||||||
|
color: #007bff;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version text styling */
|
||||||
|
.version-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888; /* Light grey color */
|
||||||
|
margin-left: auto; /* Push it to the right */
|
||||||
|
padding-top: 0.5rem; /* Align with title better */
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,9 @@
|
|||||||
--color-primary-hover: #17a44b;
|
--color-primary-hover: #17a44b;
|
||||||
--color-error: #c0392b;
|
--color-error: #c0392b;
|
||||||
--color-success: #2ecc71;
|
--color-success: #2ecc71;
|
||||||
|
/* Adding accent green if not present, or ensuring it is */
|
||||||
|
--color-accent-green: #22c55e; /* Example: A Tailwind-like green */
|
||||||
|
--color-accent-green-dark: #16a34a; /* Darker shade for hover */
|
||||||
|
|
||||||
/* Spacing */
|
/* Spacing */
|
||||||
--space-xs: 0.25rem;
|
--space-xs: 0.25rem;
|
||||||
@@ -499,4 +502,40 @@ a:hover, a:focus {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-icon {
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 90px; /* Positioned above the queue icon */
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments for floating icons */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.floating-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
right: 15px;
|
||||||
|
}
|
||||||
|
.settings-icon {
|
||||||
|
bottom: 15px; /* Adjust for smaller screens */
|
||||||
|
}
|
||||||
|
.queue-icon {
|
||||||
|
bottom: 15px; /* Adjust for smaller screens */
|
||||||
|
}
|
||||||
|
.watchlist-icon {
|
||||||
|
bottom: 75px; /* Adjust for smaller screens, above queue icon */
|
||||||
|
}
|
||||||
|
.home-btn.floating-icon { /* Specific for home button if it's also floating */
|
||||||
|
left: 15px;
|
||||||
|
bottom: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure images inside btn-icon are sized correctly */
|
||||||
|
.btn-icon img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
}
|
}
|
||||||
350
static/css/watch/watch.css
Normal file
350
static/css/watch/watch.css
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
/* static/css/watch/watch.css */
|
||||||
|
|
||||||
|
/* General styles for the watch page, similar to main.css */
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family-sans-serif);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: white;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: 1px solid var(--border-color-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-header h1 {
|
||||||
|
color: white;
|
||||||
|
font-size: 2em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-all-btn {
|
||||||
|
padding: 10px 15px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px; /* Space between icon and text */
|
||||||
|
background-color: var(--color-accent-green); /* Green background */
|
||||||
|
color: white; /* Ensure text is white for contrast */
|
||||||
|
border: none; /* Remove default border */
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-all-btn:hover {
|
||||||
|
background-color: var(--color-accent-green-dark); /* Darker green on hover */
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-all-btn img {
|
||||||
|
width: 18px; /* Slightly larger for header button */
|
||||||
|
height: 18px;
|
||||||
|
filter: brightness(0) invert(1); /* Ensure header icon is white */
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-to-search-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styling for the grid of watched items, similar to results-grid */
|
||||||
|
.results-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); /* Responsive grid */
|
||||||
|
gap: 20px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual watched item card styling, inspired by result-card from main.css */
|
||||||
|
.watched-item-card {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius-medium);
|
||||||
|
padding: 15px;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watched-item-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: var(--shadow-medium);
|
||||||
|
border-top: 1px solid var(--border-color-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-art-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 100%; /* 1:1 Aspect Ratio */
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-radius: var(--border-radius-soft);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-art {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover; /* Cover the area, cropping if necessary */
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2; /* Limit to 2 lines */
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-height: 2.4em; /* Reserve space for two lines */
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
width: 100%; /* Ensure it takes full width for centering/alignment */
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details span {
|
||||||
|
display: block; /* Each detail on a new line */
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-type-badge.artist {
|
||||||
|
background-color: var(--color-accent-blue-bg);
|
||||||
|
color: var(--color-accent-blue-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-type-badge.playlist {
|
||||||
|
background-color: var(--color-accent-green-bg);
|
||||||
|
color: var(--color-accent-green-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons (e.g., Go to item, Unwatch) */
|
||||||
|
.item-actions {
|
||||||
|
margin-top: auto;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--border-color-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions .btn-icon {
|
||||||
|
padding: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions .check-item-now-btn,
|
||||||
|
.item-actions .unwatch-item-btn {
|
||||||
|
/* Shared properties are in .item-actions .btn-icon */
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions .check-item-now-btn {
|
||||||
|
background-color: var(--color-accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions .check-item-now-btn:hover {
|
||||||
|
background-color: var(--color-accent-green-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions .check-item-now-btn img,
|
||||||
|
.item-actions .unwatch-item-btn img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions .unwatch-item-btn {
|
||||||
|
background-color: var(--color-error);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions .unwatch-item-btn:hover {
|
||||||
|
background-color: #a52a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading and Empty State - reuse from main.css if possible or define here */
|
||||||
|
.loading,
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--text-color-muted);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading.hidden,
|
||||||
|
.empty-state.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
font-size: 1.2em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-content {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
opacity: 0.7;
|
||||||
|
filter: brightness(0) invert(1); /* Added to make icon white */
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h2 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure floating icons from base.css are not obscured or mispositioned */
|
||||||
|
/* No specific overrides needed if base.css handles them well */
|
||||||
|
|
||||||
|
/* Responsive adjustments if needed */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.results-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
.watch-header h1 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
.watched-group-header {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.results-grid {
|
||||||
|
grid-template-columns: 1fr; /* Single column on very small screens */
|
||||||
|
}
|
||||||
|
.watched-item-card {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.item-name {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
.item-details {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.watched-items-group {
|
||||||
|
margin-bottom: 2rem; /* Space between groups */
|
||||||
|
}
|
||||||
|
|
||||||
|
.watched-group-header {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-group-message {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure the main watchedItemsContainer still behaves like a grid if there are few items */
|
||||||
|
#watchedItemsContainer:not(:has(.watched-items-group)) {
|
||||||
|
display: grid;
|
||||||
|
/* Assuming results-grid styles are already defined elsewhere,
|
||||||
|
or copy relevant grid styles here if needed */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notification Toast Styles */
|
||||||
|
#notificationArea {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%; /* Center horizontally */
|
||||||
|
transform: translateX(-50%); /* Adjust for exact centering */
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
gap: 10px;
|
||||||
|
width: auto; /* Allow width to be determined by content */
|
||||||
|
max-width: 90%; /* Prevent it from being too wide on large screens */
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toast {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: var(--border-radius-medium);
|
||||||
|
color: white; /* Default text color to white */
|
||||||
|
font-size: 0.9em;
|
||||||
|
box-shadow: var(--shadow-strong);
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.5s ease, transform 0.5s ease;
|
||||||
|
transform: translateX(0); /* Keep this for the hide animation */
|
||||||
|
text-align: center; /* Center text within the toast */
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toast.success {
|
||||||
|
background-color: var(--color-success); /* Use existing success color */
|
||||||
|
/* color: var(--color-accent-green-text); REMOVE - use white */
|
||||||
|
/* border: 1px solid var(--color-accent-green-text); REMOVE */
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toast.error {
|
||||||
|
background-color: var(--color-error); /* Use existing error color */
|
||||||
|
/* color: var(--color-accent-red-text); REMOVE - use white */
|
||||||
|
/* border: 1px solid var(--color-accent-red-text); REMOVE */
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toast.hide {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(100%); /* Slide down for exit, or could keep translateX if preferred */
|
||||||
|
}
|
||||||
@@ -50,6 +50,10 @@
|
|||||||
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home">
|
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home">
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<a href="/watchlist" class="btn-icon watchlist-icon floating-icon" aria-label="Watchlist" title="Go to Watchlist">
|
||||||
|
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="queueIcon"
|
id="queueIcon"
|
||||||
class="btn-icon queue-icon floating-icon"
|
class="btn-icon queue-icon floating-icon"
|
||||||
|
|||||||
@@ -55,6 +55,10 @@
|
|||||||
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home">
|
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home">
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<a href="/watchlist" class="btn-icon watchlist-icon floating-icon" aria-label="Watchlist" title="Go to Watchlist">
|
||||||
|
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="queueIcon"
|
id="queueIcon"
|
||||||
class="btn-icon queue-icon floating-icon"
|
class="btn-icon queue-icon floating-icon"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<div class="config-container">
|
<div class="config-container">
|
||||||
<header class="config-header">
|
<header class="config-header">
|
||||||
<h1 class="header-title">Configuration</h1>
|
<h1 class="header-title">Configuration</h1>
|
||||||
|
<span class="version-text">2.0.0</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="account-config card">
|
<div class="account-config card">
|
||||||
@@ -228,6 +229,20 @@
|
|||||||
|
|
||||||
<div class="watch-options-config card">
|
<div class="watch-options-config card">
|
||||||
<h2 class="section-title">Watch Options</h2>
|
<h2 class="section-title">Watch Options</h2>
|
||||||
|
<div class="config-item">
|
||||||
|
<label>Enable Watch Feature:</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="watchEnabledToggle" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<div class="setting-description">
|
||||||
|
Enable or disable the entire watch feature (monitoring playlists and artists for new content).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="watchEnabledWarning" class="config-item urgent-warning-message" style="display: none;">
|
||||||
|
<svg class="warning-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24px" height="24px"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></svg>
|
||||||
|
Warning: Enable "Real time downloading" in the Download Settings to avoid rate-limiting issues. If you don't, you WILL (pretty much immediately) encounter API rate limits, and the watch feature WILL break.
|
||||||
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<label for="watchedArtistAlbumGroup">Artist Page - Album Groups to Watch:</label>
|
<label for="watchedArtistAlbumGroup">Artist Page - Album Groups to Watch:</label>
|
||||||
<div id="watchedArtistAlbumGroupChecklist" class="checklist-container">
|
<div id="watchedArtistAlbumGroupChecklist" class="checklist-container">
|
||||||
@@ -306,6 +321,10 @@
|
|||||||
<img src="{{ url_for('static', filename='images/arrow-left.svg') }}" alt="Back" />
|
<img src="{{ url_for('static', filename='images/arrow-left.svg') }}" alt="Back" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="/watchlist" class="btn-icon watchlist-icon floating-icon" aria-label="Watchlist" title="Go to Watchlist">
|
||||||
|
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="queueIcon"
|
id="queueIcon"
|
||||||
class="btn-icon queue-icon floating-icon"
|
class="btn-icon queue-icon floating-icon"
|
||||||
|
|||||||
@@ -63,6 +63,10 @@
|
|||||||
<img src="{{ url_for('static', filename='images/settings.svg') }}" alt="Settings" onerror="handleImageError(this)"/>
|
<img src="{{ url_for('static', filename='images/settings.svg') }}" alt="Settings" onerror="handleImageError(this)"/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="/watchlist" class="btn-icon watchlist-icon floating-icon" aria-label="Watchlist" title="Go to Watchlist">
|
||||||
|
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="queueIcon"
|
id="queueIcon"
|
||||||
class="btn-icon queue-icon floating-icon"
|
class="btn-icon queue-icon floating-icon"
|
||||||
|
|||||||
@@ -62,6 +62,10 @@
|
|||||||
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home">
|
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home">
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<a href="/watchlist" class="btn-icon watchlist-icon floating-icon" aria-label="Watchlist" title="Go to Watchlist">
|
||||||
|
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="queueIcon"
|
id="queueIcon"
|
||||||
class="btn-icon queue-icon floating-icon"
|
class="btn-icon queue-icon floating-icon"
|
||||||
|
|||||||
@@ -49,6 +49,10 @@
|
|||||||
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home">
|
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home">
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<a href="/watchlist" class="btn-icon watchlist-icon floating-icon" aria-label="Watchlist" title="Go to Watchlist">
|
||||||
|
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="queueIcon"
|
id="queueIcon"
|
||||||
class="btn-icon queue-icon floating-icon"
|
class="btn-icon queue-icon floating-icon"
|
||||||
|
|||||||
68
static/html/watch.html
Normal file
68
static/html/watch.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Watched Items - Spotizerr</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/base.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/watch/watch.css') }}">
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Helper function to handle image loading errors
|
||||||
|
function handleImageError(img) {
|
||||||
|
img.src = '/static/images/placeholder.jpg'; // Ensure this placeholder exists
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<div class="watch-header">
|
||||||
|
<h1>Watched Artists & Playlists</h1>
|
||||||
|
<button id="checkAllWatchedBtn" class="btn btn-secondary check-all-btn">
|
||||||
|
<img src="{{ url_for('static', filename='images/refresh-cw.svg') }}" alt="Refresh"> Check All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="watchedItemsContainer" class="results-grid">
|
||||||
|
<!-- Watched items will be dynamically inserted here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loadingWatchedItems" class="loading hidden">
|
||||||
|
<div class="loading-indicator">Loading watched items...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="emptyWatchedItems" class="empty-state hidden">
|
||||||
|
<div class="empty-state-content">
|
||||||
|
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Binoculars" class="empty-state-icon" />
|
||||||
|
<h2>Nothing to see here yet!</h2>
|
||||||
|
<p>Start watching artists or playlists, and they'll appear here.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fixed floating buttons for settings and queue -->
|
||||||
|
<a href="/" class="btn-icon home-btn floating-icon settings-icon" aria-label="Return to Home" title="Return to Home">
|
||||||
|
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home" onerror="handleImageError(this)"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/watchlist" class="btn-icon watchlist-icon floating-icon" aria-label="Watchlist" title="Go to Watchlist">
|
||||||
|
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="queueIcon"
|
||||||
|
class="btn-icon queue-icon floating-icon"
|
||||||
|
aria-label="Download queue"
|
||||||
|
aria-controls="downloadQueue"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue" onerror="handleImageError(this)"/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<script type="module" src="{{ url_for('static', filename='js/watch.js') }}"></script>
|
||||||
|
<!-- Include queue.js if queueIcon functionality is desired on this page too -->
|
||||||
|
<script type="module" src="{{ url_for('static', filename='js/queue.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
static/images/binoculars.svg
Normal file
12
static/images/binoculars.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>binoculars-filled</title>
|
||||||
|
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="icon" fill="#000000" transform="translate(64.000000, 64.000000)">
|
||||||
|
<path d="M277.333333,-4.26325641e-14 C300.897483,-4.69612282e-14 320,19.1025173 320,42.6666667 L320.001038,66.6886402 C356.805359,76.1619142 384,109.571799 384,149.333333 L384,298.666667 C384,345.794965 345.794965,384 298.666667,384 C251.538368,384 213.333333,345.794965 213.333333,298.666667 L213.334343,290.517566 C207.67282,295.585196 200.196268,298.666667 192,298.666667 C183.803732,298.666667 176.32718,295.585196 170.665657,290.517566 L170.666667,298.666667 C170.666667,345.794965 132.461632,384 85.3333333,384 C38.2050347,384 -4.26325641e-14,345.794965 -4.26325641e-14,298.666667 L-4.26325641e-14,149.333333 C-4.75019917e-14,109.57144 27.1951335,76.1613096 63.9999609,66.6883831 L64,42.6666667 C64,19.1025173 83.1025173,-3.83039001e-14 106.666667,-4.26325641e-14 C130.230816,-4.69612282e-14 149.333333,19.1025173 149.333333,42.6666667 L149.333764,69.7082895 C158.827303,75.200153 166.008403,84.2448998 169.058923,95.0243894 C174.872894,89.046446 183.002825,85.3333333 192,85.3333333 C200.997175,85.3333333 209.127106,89.046446 214.940994,95.0238729 C217.991694,84.2447833 225.172752,75.2001286 234.666215,69.7083018 L234.666667,42.6666667 C234.666667,19.1025173 253.769184,-3.83039001e-14 277.333333,-4.26325641e-14 Z M85.3333333,256 C61.769184,256 42.6666667,275.102517 42.6666667,298.666667 C42.6666667,322.230816 61.769184,341.333333 85.3333333,341.333333 C108.897483,341.333333 128,322.230816 128,298.666667 C128,275.102517 108.897483,256 85.3333333,256 Z M298.666667,256 C275.102517,256 256,275.102517 256,298.666667 C256,322.230816 275.102517,341.333333 298.666667,341.333333 C322.230816,341.333333 341.333333,322.230816 341.333333,298.666667 C341.333333,275.102517 322.230816,256 298.666667,256 Z" id="Combined-Shape">
|
||||||
|
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
4
static/images/refresh-cw.svg
Normal file
4
static/images/refresh-cw.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" d="M17.8069373,7 C16.4464601,5.07869636 14.3936238,4 12,4 C7.581722,4 4,7.581722 4,12 L2,12 C2,6.4771525 6.4771525,2 12,2 C14.8042336,2 17.274893,3.18251178 19,5.27034886 L19,4 L21,4 L21,9 L16,9 L16,7 L17.8069373,7 Z M6.19306266,17 C7.55353989,18.9213036 9.60637619,20 12,20 C16.418278,20 20,16.418278 20,12 L22,12 C22,17.5228475 17.5228475,22 12,22 C9.19576641,22 6.72510698,20.8174882 5,18.7296511 L5,20 L3,20 L3,15 L8,15 L8,17 L6.19306266,17 Z M12.0003283,15.9983464 C11.4478622,15.9983464 11,15.5506311 11,14.9983464 C11,14.4460616 11.4478622,13.9983464 12.0003283,13.9983464 C12.5527943,13.9983464 13.0006565,14.4460616 13.0006565,14.9983464 C13.0006565,15.5506311 12.5527943,15.9983464 12.0003283,15.9983464 Z M11.0029544,6.99834639 L13.0036109,6.99834639 L13.0036109,12.9983464 L11.0029544,12.9983464 L11.0029544,6.99834639 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
Reference in New Issue
Block a user