This commit is contained in:
cool.gitter.not.me.again.duh
2025-05-29 12:00:57 -06:00
parent 5f3e78e5f4
commit 03f132a9f3
18 changed files with 1318 additions and 37 deletions

7
app.py
View File

@@ -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.

View File

@@ -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

View File

@@ -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
} }

View File

@@ -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.")

View File

@@ -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
View 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;
}

View File

@@ -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 */
} }

View File

@@ -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
View 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 */
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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
View 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>

View 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

View 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