Refactoring profond de pmoparadise
This commit is contained in:
@@ -166,73 +166,221 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="channel-tracks-section">
|
||||
<div class="channel-status-section">
|
||||
<div class="section-header">
|
||||
<h3>🎧 Channel Tracks (Source API)</h3>
|
||||
<button class="btn-secondary" @click="fetchChannelTracks" :disabled="channelBrowseLoading">
|
||||
<span v-if="channelBrowseLoading">⏳ Refreshing…</span>
|
||||
<span v-else>Refresh Tracks</span>
|
||||
<h3>📡 Channel Status</h3>
|
||||
<button class="btn-secondary" @click="fetchChannelStatus" :disabled="channelStatusLoading">
|
||||
<span v-if="channelStatusLoading">⏳ Refreshing…</span>
|
||||
<span v-else>Refresh Status</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="channelStatusError" class="error-message">
|
||||
❌ {{ channelStatusError }}
|
||||
</div>
|
||||
<div v-else-if="channelStatus">
|
||||
<div class="status-grid">
|
||||
<div class="status-card">
|
||||
<div class="status-label">Channel</div>
|
||||
<div class="status-value">{{ channelStatus.slug }}</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">Active Clients</div>
|
||||
<div class="status-value">{{ channelStatus.active_clients }}</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">Queue Length</div>
|
||||
<div class="status-value">{{ channelStatus.queue_length }}</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">Update ID</div>
|
||||
<div class="status-value">{{ channelStatus.update_id }}</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">Last Change</div>
|
||||
<div class="status-value">
|
||||
{{ channelStatus.last_change ? formatTimestamp(new Date(channelStatus.last_change)) : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">History Entries</div>
|
||||
<div class="status-value">
|
||||
{{ channelStatus.history_entries }} / {{ channelStatus.history_max_tracks }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">Cache Collection</div>
|
||||
<div class="status-value">{{ channelStatus.cache_collection_id }}</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">Cache Tracks</div>
|
||||
<div class="status-value">
|
||||
{{ channelStatus.cache_cached_tracks }} / {{ channelStatus.cache_total_tracks }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-meta" v-if="channelStatusLastUpdated">
|
||||
Last refresh: {{ formatTimestamp(channelStatusLastUpdated) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="channel-tracks-section">
|
||||
<div class="section-header">
|
||||
<h3>🎧 Live Playlist</h3>
|
||||
<div class="section-actions">
|
||||
<button class="btn-tertiary" @click="refreshChannelData" :disabled="channelPlaylistLoading || channelStatusLoading">
|
||||
<span v-if="channelPlaylistLoading || channelStatusLoading">⏳ Refreshing…</span>
|
||||
<span v-else>Refresh All</span>
|
||||
</button>
|
||||
<button class="btn-secondary" @click="fetchChannelPlaylist" :disabled="channelPlaylistLoading">
|
||||
<span v-if="channelPlaylistLoading">⏳ Loading…</span>
|
||||
<span v-else>Refresh Playlist</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-meta">
|
||||
<span>Object:</span>
|
||||
<code>{{ channelBrowse.object_id || channelObjectId(selectedChannel) }}</code>
|
||||
<span>Items:</span>
|
||||
<strong>{{ channelBrowse.returned_items }}</strong>
|
||||
<span v-if="channelBrowse.total">/ {{ channelBrowse.total }}</span>
|
||||
<span v-if="channelBrowse.update_id">Update ID: {{ channelBrowse.update_id }}</span>
|
||||
<span v-if="channelBrowseLastUpdated">
|
||||
Last refresh: {{ formatTimestamp(channelBrowseLastUpdated) }}
|
||||
<span>Queue length:</span>
|
||||
<strong>{{ channelPlaylist.queue_length }}</strong>
|
||||
<span v-if="channelPlaylist.update_id">Update ID: {{ channelPlaylist.update_id }}</span>
|
||||
<span v-if="channelPlaylistLastUpdated">
|
||||
Last refresh: {{ formatTimestamp(channelPlaylistLastUpdated) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="channelBrowseError" class="error-message">
|
||||
❌ {{ channelBrowseError }}
|
||||
<div v-if="channelPlaylistError" class="error-message">
|
||||
❌ {{ channelPlaylistError }}
|
||||
</div>
|
||||
<div v-else-if="channelBrowseLoading" class="loading-message">
|
||||
⏳ Loading channel tracks…
|
||||
<div v-else-if="channelPlaylistLoading" class="loading-message">
|
||||
⏳ Loading playlist…
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="channelBrowse.containers.length" class="sub-container-notice">
|
||||
{{ channelBrowse.containers.length }} sub container(s) available.
|
||||
</div>
|
||||
<div v-if="channelBrowse.items.length" class="track-grid">
|
||||
<div v-if="channelPlaylist.items.length" class="track-grid">
|
||||
<div
|
||||
v-for="item in channelBrowse.items"
|
||||
:key="item.id"
|
||||
:class="['track-card', { active: activeTrackId === item.id }]"
|
||||
v-for="item in channelPlaylist.items"
|
||||
:key="trackObjectId(item)"
|
||||
:class="['track-card', { active: activeTrackId === trackObjectId(item) }]"
|
||||
>
|
||||
<div class="track-card-header">
|
||||
<span :class="trackStatusClass(item.__status)">
|
||||
{{ trackStatusLabel(item.__status) }}
|
||||
<span :class="cacheStatusClass(item.cache_status)">
|
||||
{{ cacheStatusLabel(item.cache_status) }}
|
||||
</span>
|
||||
<span v-if="item.update_id" class="track-metadata">update {{ item.update_id }}</span>
|
||||
<span class="track-metadata">Pending listeners: {{ item.pending_clients }}</span>
|
||||
</div>
|
||||
<div class="track-headline">
|
||||
<div class="track-title">{{ item.title }}</div>
|
||||
<div class="track-artist">{{ item.artist || item.creator || 'Unknown artist' }}</div>
|
||||
<div class="track-artist">{{ item.artist || 'Unknown artist' }}</div>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
<span v-if="item.album">{{ item.album }}</span>
|
||||
<span v-if="item.resources && item.resources.length && item.resources[0].duration">
|
||||
⏱ {{ item.resources[0].duration }}
|
||||
</span>
|
||||
<span v-if="item.duration_ms">⏱ {{ formatDuration(item.duration_ms) }}</span>
|
||||
<span v-if="item.elapsed_ms">▶️ @{{ formatDuration(item.elapsed_ms) }}</span>
|
||||
<span v-if="item.started_at">🕒 {{ formatTimestamp(new Date(item.started_at)) }}</span>
|
||||
<span v-if="item.cache_status?.size_bytes">💾 {{ formatBytes(item.cache_status.size_bytes) }}</span>
|
||||
</div>
|
||||
<div class="track-actions">
|
||||
<button class="btn-secondary" @click="playTrackItem(item)">▶️ Play Track</button>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
@click="requestCacheForTrack(item)"
|
||||
:disabled="trackExtrasFor(item).cacheRequestLoading"
|
||||
>
|
||||
<span v-if="trackExtrasFor(item).cacheRequestLoading">⏳ Caching…</span>
|
||||
<span v-else>💾 Request Cache</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn-tertiary"
|
||||
@click="refreshTrackCacheStatus(item)"
|
||||
:disabled="trackExtrasFor(item).cacheStatusLoading"
|
||||
>
|
||||
<span v-if="trackExtrasFor(item).cacheStatusLoading">⏳ Updating…</span>
|
||||
<span v-else>🔄 Cache Status</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn-tertiary"
|
||||
@click="fetchTrackFormats(item)"
|
||||
:disabled="trackExtrasFor(item).formatsLoading"
|
||||
>
|
||||
<span v-if="trackExtrasFor(item).formatsLoading">⏳ Formats…</span>
|
||||
<span v-else>🎚️ Formats</span>
|
||||
</button>
|
||||
<a
|
||||
v-for="resource in item.resources"
|
||||
:key="resource.url"
|
||||
:href="resource.url"
|
||||
v-if="trackExtrasFor(item).uri"
|
||||
:href="trackExtrasFor(item).uri"
|
||||
target="_blank"
|
||||
class="stream-link"
|
||||
>
|
||||
Open resource
|
||||
Open resolved URI
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="trackExtrasFor(item).cacheRequestMessage" class="inline-success">
|
||||
✅ {{ trackExtrasFor(item).cacheRequestMessage }}
|
||||
</div>
|
||||
<div v-if="trackExtrasFor(item).cacheError" class="inline-error">
|
||||
❌ {{ trackExtrasFor(item).cacheError }}
|
||||
</div>
|
||||
<div v-if="trackExtrasFor(item).resolveError" class="inline-error">
|
||||
❌ Resolve error: {{ trackExtrasFor(item).resolveError }}
|
||||
</div>
|
||||
<div v-if="trackExtrasFor(item).formatsError" class="inline-error">
|
||||
❌ Formats error: {{ trackExtrasFor(item).formatsError }}
|
||||
</div>
|
||||
<div
|
||||
v-if="trackExtrasFor(item).formats && trackExtrasFor(item).formats.length"
|
||||
class="formats-list"
|
||||
>
|
||||
<div
|
||||
v-for="format in trackExtrasFor(item).formats"
|
||||
:key="format.format_id"
|
||||
class="format-row"
|
||||
>
|
||||
<strong>{{ format.format_id }}</strong>
|
||||
<span>{{ format.mime_type }}</span>
|
||||
<span v-if="format.sample_rate">{{ format.sample_rate }} Hz</span>
|
||||
<span v-if="format.bit_depth">{{ format.bit_depth }} bit</span>
|
||||
<span v-if="format.bitrate">{{ format.bitrate }} kbps</span>
|
||||
<span v-if="format.channels">{{ format.channels }} ch</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-placeholder">
|
||||
No cached tracks yet for this channel. Refresh after playback starts.
|
||||
No tracks currently queued. Try refreshing after playback starts.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="channel-history-section">
|
||||
<div class="section-header">
|
||||
<h3>🕰️ Recent History</h3>
|
||||
<button class="btn-secondary" @click="fetchChannelHistory" :disabled="channelHistoryLoading">
|
||||
<span v-if="channelHistoryLoading">⏳ Loading…</span>
|
||||
<span v-else>Refresh History</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="section-meta" v-if="channelHistoryLastUpdated">
|
||||
Last refresh: {{ formatTimestamp(channelHistoryLastUpdated) }}
|
||||
</div>
|
||||
<div v-if="channelHistoryError" class="error-message">
|
||||
❌ {{ channelHistoryError }}
|
||||
</div>
|
||||
<div v-else-if="channelHistoryLoading" class="loading-message">
|
||||
⏳ Loading history…
|
||||
</div>
|
||||
<div v-else class="history-list">
|
||||
<div v-if="channelHistory.length === 0" class="empty-placeholder">
|
||||
No history entries yet.
|
||||
</div>
|
||||
<div
|
||||
v-for="entry in channelHistory"
|
||||
:key="`${entry.track_id}-${entry.started_at}`"
|
||||
class="history-item"
|
||||
>
|
||||
<div class="history-title">{{ entry.title }}</div>
|
||||
<div class="history-meta">
|
||||
<span>{{ entry.artist }}</span>
|
||||
<span v-if="entry.album">• {{ entry.album }}</span>
|
||||
<span>• {{ formatDuration(entry.duration_ms) }}</span>
|
||||
<span>• {{ formatTimestamp(new Date(entry.started_at)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -379,22 +527,31 @@ const blockSearchLoading = ref(false)
|
||||
const blockSearchError = ref('')
|
||||
const channelsError = ref('')
|
||||
const bitratesError = ref('')
|
||||
const channelBrowse = ref({
|
||||
object_id: '',
|
||||
containers: [],
|
||||
const channelStatus = ref(null)
|
||||
const channelStatusLoading = ref(false)
|
||||
const channelStatusError = ref('')
|
||||
const channelStatusLastUpdated = ref(null)
|
||||
|
||||
const channelPlaylist = ref({
|
||||
items: [],
|
||||
returned_containers: 0,
|
||||
returned_items: 0,
|
||||
total: 0,
|
||||
update_id: 0
|
||||
queue_length: 0,
|
||||
update_id: 0,
|
||||
slug: '',
|
||||
channel_id: selectedChannel.value
|
||||
})
|
||||
const channelBrowseLoading = ref(false)
|
||||
const channelBrowseError = ref('')
|
||||
const channelBrowseLastUpdated = ref(null)
|
||||
const channelPlaylistLoading = ref(false)
|
||||
const channelPlaylistError = ref('')
|
||||
const channelPlaylistLastUpdated = ref(null)
|
||||
|
||||
const channelHistory = ref([])
|
||||
const channelHistoryLoading = ref(false)
|
||||
const channelHistoryError = ref('')
|
||||
const channelHistoryLastUpdated = ref(null)
|
||||
|
||||
const trackExtras = ref({})
|
||||
let refreshTimerId = null
|
||||
let channelRefreshTimerId = null
|
||||
const CHANNEL_TRACKS_REFRESH_INTERVAL = 5000
|
||||
let channelTracksInFlight = false
|
||||
const CHANNEL_REFRESH_INTERVAL = 7000
|
||||
|
||||
// Format duration from milliseconds to MM:SS
|
||||
function formatDuration(ms) {
|
||||
@@ -444,6 +601,10 @@ function channelObjectId(channelId) {
|
||||
return `${SOURCE_ID}:channel:${channelId}`
|
||||
}
|
||||
|
||||
function trackObjectId(item) {
|
||||
return item?.track_id || item?.id || item?.object_id || ''
|
||||
}
|
||||
|
||||
function playAudio(url) {
|
||||
if (!url) {
|
||||
audioError.value = 'No audio URL available'
|
||||
@@ -496,91 +657,311 @@ async function refreshNowPlaying() {
|
||||
}
|
||||
}
|
||||
|
||||
function deriveTrackStatus(item) {
|
||||
const url = item?.resources?.[0]?.url || ''
|
||||
if (!url) {
|
||||
return 'pending'
|
||||
}
|
||||
if (url.includes('/audio/flac/') && !url.includes('#')) {
|
||||
return 'cached'
|
||||
}
|
||||
if (url.includes('#')) {
|
||||
return 'downloading'
|
||||
}
|
||||
return 'external'
|
||||
}
|
||||
|
||||
function trackStatusLabel(status) {
|
||||
function cacheStatusLabel(cacheInfo) {
|
||||
const status = cacheInfo?.status || 'not_cached'
|
||||
switch (status) {
|
||||
case 'cached':
|
||||
return 'Cached'
|
||||
case 'downloading':
|
||||
return 'Downloading'
|
||||
case 'external':
|
||||
return 'External'
|
||||
case 'caching':
|
||||
return cacheInfo?.progress != null
|
||||
? `Caching ${(cacheInfo.progress * 100).toFixed(0)}%`
|
||||
: 'Caching'
|
||||
case 'failed':
|
||||
return 'Failed'
|
||||
case 'not_cached':
|
||||
default:
|
||||
return 'Pending'
|
||||
return 'Not cached'
|
||||
}
|
||||
}
|
||||
|
||||
function trackStatusClass(status) {
|
||||
function cacheStatusClass(cacheInfo) {
|
||||
const status = cacheInfo?.status || 'not_cached'
|
||||
return {
|
||||
'status-badge': true,
|
||||
cached: status === 'cached',
|
||||
downloading: status === 'downloading',
|
||||
external: status === 'external',
|
||||
pending: status === 'pending'
|
||||
caching: status === 'caching',
|
||||
failed: status === 'failed',
|
||||
pending: status === 'not_cached'
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChannelTracks(options = {}) {
|
||||
const { silent = false } = options
|
||||
if (channelTracksInFlight) {
|
||||
return
|
||||
function formatBytes(bytes) {
|
||||
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) {
|
||||
return '0 B'
|
||||
}
|
||||
channelTracksInFlight = true
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let value = bytes
|
||||
let unitIndex = 0
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
async function fetchChannelStatus({ silent = false } = {}) {
|
||||
if (!silent) {
|
||||
channelBrowseLoading.value = true
|
||||
channelStatusLoading.value = true
|
||||
}
|
||||
channelBrowseError.value = ''
|
||||
|
||||
channelStatusError.value = ''
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('object_id', channelObjectId(selectedChannel.value))
|
||||
params.set('requested_count', '0')
|
||||
|
||||
const response = await fetch(`${SOURCE_API_BASE}/${SOURCE_ID}/browse?${params.toString()}`)
|
||||
const response = await fetch(`${API_BASE}/channels/${selectedChannel.value}/status`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
channelBrowse.value = {
|
||||
object_id: data.object_id || channelObjectId(selectedChannel.value),
|
||||
containers: data.containers ?? [],
|
||||
items: data.items ?? [],
|
||||
returned_containers: data.returned_containers ?? (data.containers?.length ?? 0),
|
||||
returned_items: data.returned_items ?? (data.items?.length ?? 0),
|
||||
total: data.total ?? ((data.containers?.length ?? 0) + (data.items?.length ?? 0)),
|
||||
update_id: data.update_id ?? 0
|
||||
}
|
||||
// annotate each item with status for UI
|
||||
channelBrowse.value.items = channelBrowse.value.items.map((item) => ({
|
||||
...item,
|
||||
__status: deriveTrackStatus(item)
|
||||
}))
|
||||
channelBrowseLastUpdated.value = new Date()
|
||||
channelStatus.value = await response.json()
|
||||
channelStatusLastUpdated.value = new Date()
|
||||
} catch (e) {
|
||||
channelBrowseError.value = `Failed to load channel tracks: ${e.message}`
|
||||
console.error('Error fetching channel tracks:', e)
|
||||
channelStatusError.value = `Failed to load channel status: ${e.message}`
|
||||
console.error('Error fetching channel status:', e)
|
||||
} finally {
|
||||
channelTracksInFlight = false
|
||||
if (!silent) {
|
||||
channelBrowseLoading.value = false
|
||||
channelStatusLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChannelPlaylist({ silent = false, limit = 24 } = {}) {
|
||||
if (!silent) {
|
||||
channelPlaylistLoading.value = true
|
||||
}
|
||||
channelPlaylistError.value = ''
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (limit != null) {
|
||||
params.set('limit', String(limit))
|
||||
}
|
||||
const response = await fetch(
|
||||
`${API_BASE}/channels/${selectedChannel.value}/playlist?${params.toString()}`
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
channelPlaylist.value = {
|
||||
...data,
|
||||
items: (data.items || []).map((item) => ({
|
||||
...item,
|
||||
cache_status: item.cache_status || { status: 'not_cached', progress: 0 }
|
||||
}))
|
||||
}
|
||||
channelPlaylistLastUpdated.value = new Date()
|
||||
const validIds = new Set(channelPlaylist.value.items.map((item) => trackObjectId(item)).filter(Boolean))
|
||||
trackExtras.value = Object.fromEntries(
|
||||
Object.entries(trackExtras.value).filter(([id]) => validIds.has(id))
|
||||
)
|
||||
} catch (e) {
|
||||
channelPlaylistError.value = `Failed to load playlist: ${e.message}`
|
||||
console.error('Error fetching channel playlist:', e)
|
||||
} finally {
|
||||
if (!silent) {
|
||||
channelPlaylistLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChannelHistory({ silent = false, limit = 25 } = {}) {
|
||||
if (!silent) {
|
||||
channelHistoryLoading.value = true
|
||||
}
|
||||
channelHistoryError.value = ''
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (limit != null) {
|
||||
params.set('limit', String(limit))
|
||||
}
|
||||
const response = await fetch(
|
||||
`${API_BASE}/channels/${selectedChannel.value}/history?${params.toString()}`
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
channelHistory.value = data.entries || []
|
||||
channelHistoryLastUpdated.value = new Date()
|
||||
} catch (e) {
|
||||
channelHistoryError.value = `Failed to load history: ${e.message}`
|
||||
console.error('Error fetching channel history:', e)
|
||||
} finally {
|
||||
if (!silent) {
|
||||
channelHistoryLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshChannelData({ silent = false } = {}) {
|
||||
await Promise.all([
|
||||
fetchChannelStatus({ silent }),
|
||||
fetchChannelPlaylist({ silent }),
|
||||
fetchChannelHistory({ silent })
|
||||
])
|
||||
}
|
||||
|
||||
function updateTrackExtras(trackId, patch) {
|
||||
if (!trackId) {
|
||||
return
|
||||
}
|
||||
const current = trackExtras.value[trackId] || {
|
||||
uri: '',
|
||||
lastResolvedAt: null,
|
||||
resolving: false,
|
||||
resolveError: '',
|
||||
formats: [],
|
||||
formatsLoading: false,
|
||||
formatsError: '',
|
||||
cacheRequestLoading: false,
|
||||
cacheRequestMessage: '',
|
||||
cacheError: '',
|
||||
cacheStatusLoading: false
|
||||
}
|
||||
trackExtras.value = {
|
||||
...trackExtras.value,
|
||||
[trackId]: {
|
||||
...current,
|
||||
...patch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function trackExtrasFor(item) {
|
||||
const trackId = trackObjectId(item)
|
||||
return trackExtras.value[trackId] || {}
|
||||
}
|
||||
|
||||
async function resolveTrackUri(trackId) {
|
||||
if (!trackId) {
|
||||
throw new Error('Missing track identifier')
|
||||
}
|
||||
updateTrackExtras(trackId, { resolving: true, resolveError: '' })
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('object_id', trackId)
|
||||
const response = await fetch(`${SOURCE_API_BASE}/${SOURCE_ID}/resolve?${params.toString()}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
updateTrackExtras(trackId, {
|
||||
resolving: false,
|
||||
uri: data.uri,
|
||||
lastResolvedAt: new Date()
|
||||
})
|
||||
return data.uri
|
||||
} catch (e) {
|
||||
updateTrackExtras(trackId, { resolving: false, resolveError: e.message })
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTrackCacheStatus(item) {
|
||||
const trackId = trackObjectId(item)
|
||||
if (!trackId) {
|
||||
return
|
||||
}
|
||||
updateTrackExtras(trackId, { cacheStatusLoading: true, cacheError: '' })
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('object_id', trackId)
|
||||
const response = await fetch(
|
||||
`${SOURCE_API_BASE}/${SOURCE_ID}/cache/status?${params.toString()}`
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
const status = data.status || { status: 'not_cached' }
|
||||
channelPlaylist.value = {
|
||||
...channelPlaylist.value,
|
||||
items: channelPlaylist.value.items.map((entry) =>
|
||||
trackObjectId(entry) === trackId ? { ...entry, cache_status: status } : entry
|
||||
)
|
||||
}
|
||||
updateTrackExtras(trackId, { cacheStatusLoading: false, cacheError: '', cacheStatus: status })
|
||||
} catch (e) {
|
||||
updateTrackExtras(trackId, { cacheStatusLoading: false, cacheError: e.message })
|
||||
console.error('Error refreshing cache status:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function requestCacheForTrack(item) {
|
||||
const trackId = trackObjectId(item)
|
||||
if (!trackId) {
|
||||
return
|
||||
}
|
||||
updateTrackExtras(trackId, {
|
||||
cacheRequestLoading: true,
|
||||
cacheRequestMessage: '',
|
||||
cacheError: ''
|
||||
})
|
||||
try {
|
||||
const response = await fetch(`${SOURCE_API_BASE}/${SOURCE_ID}/cache`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ object_id: trackId })
|
||||
})
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}))
|
||||
const message = body?.error || response.statusText
|
||||
throw new Error(message)
|
||||
}
|
||||
const data = await response.json()
|
||||
const status = data.status || data?.cache_status
|
||||
if (status) {
|
||||
channelPlaylist.value = {
|
||||
...channelPlaylist.value,
|
||||
items: channelPlaylist.value.items.map((entry) =>
|
||||
trackObjectId(entry) === trackId ? { ...entry, cache_status: status } : entry
|
||||
)
|
||||
}
|
||||
}
|
||||
updateTrackExtras(trackId, {
|
||||
cacheRequestLoading: false,
|
||||
cacheRequestMessage: 'Cache request accepted'
|
||||
})
|
||||
} catch (e) {
|
||||
updateTrackExtras(trackId, {
|
||||
cacheRequestLoading: false,
|
||||
cacheRequestMessage: '',
|
||||
cacheError: e.message
|
||||
})
|
||||
console.error('Error requesting cache:', e)
|
||||
} finally {
|
||||
await refreshTrackCacheStatus(item)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTrackFormats(item) {
|
||||
const trackId = trackObjectId(item)
|
||||
if (!trackId) {
|
||||
return
|
||||
}
|
||||
const extras = trackExtrasFor(item)
|
||||
if (extras.formats?.length && !extras.formatsError) {
|
||||
return
|
||||
}
|
||||
updateTrackExtras(trackId, { formatsLoading: true, formatsError: '' })
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('object_id', trackId)
|
||||
const response = await fetch(
|
||||
`${SOURCE_API_BASE}/${SOURCE_ID}/formats?${params.toString()}`
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
updateTrackExtras(trackId, {
|
||||
formatsLoading: false,
|
||||
formats: data.formats || []
|
||||
})
|
||||
} catch (e) {
|
||||
updateTrackExtras(trackId, { formatsLoading: false, formatsError: e.message })
|
||||
console.error('Error fetching track formats:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch available channels
|
||||
async function fetchChannels() {
|
||||
try {
|
||||
@@ -635,15 +1016,17 @@ function selectChannel(channelId) {
|
||||
}
|
||||
|
||||
async function changeChannel() {
|
||||
trackExtras.value = {}
|
||||
await refreshNowPlaying()
|
||||
await fetchChannelTracks()
|
||||
await refreshChannelData()
|
||||
blockSearchResult.value = null
|
||||
blockSearchError.value = ''
|
||||
}
|
||||
|
||||
async function changeBitrate() {
|
||||
trackExtras.value = {}
|
||||
await refreshNowPlaying()
|
||||
await fetchChannelTracks()
|
||||
await refreshChannelData()
|
||||
blockSearchResult.value = null
|
||||
blockSearchError.value = ''
|
||||
}
|
||||
@@ -744,16 +1127,38 @@ function clearBlockSearch() {
|
||||
blockSearchError.value = ''
|
||||
}
|
||||
|
||||
function playTrackItem(item) {
|
||||
const resource = item?.resources?.find(res => res.url)
|
||||
if (!resource) {
|
||||
audioError.value = 'No audio resource available for this track'
|
||||
async function playTrackItem(item) {
|
||||
const trackId = trackObjectId(item)
|
||||
if (!trackId) {
|
||||
audioError.value = 'Unable to determine track identifier'
|
||||
isPlaying.value = false
|
||||
return
|
||||
}
|
||||
|
||||
activeTrackId.value = item.id
|
||||
playAudio(resource.url)
|
||||
try {
|
||||
let uri = null
|
||||
try {
|
||||
uri = await resolveTrackUri(trackId)
|
||||
} catch (resolveError) {
|
||||
console.warn('Falling back to direct resource due to resolve error:', resolveError)
|
||||
}
|
||||
|
||||
if (!uri) {
|
||||
const fallback = item?.resources?.find((res) => res.url)?.url
|
||||
uri = fallback
|
||||
}
|
||||
|
||||
if (!uri) {
|
||||
throw new Error('No audio resource available for this track')
|
||||
}
|
||||
|
||||
activeTrackId.value = trackId
|
||||
playAudio(uri)
|
||||
} catch (e) {
|
||||
audioError.value = e.message
|
||||
isPlaying.value = false
|
||||
activeTrackId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on mount
|
||||
@@ -761,7 +1166,7 @@ onMounted(async () => {
|
||||
await fetchChannels()
|
||||
await fetchBitrates()
|
||||
await refreshNowPlaying()
|
||||
await fetchChannelTracks()
|
||||
await refreshChannelData()
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
refreshTimerId = window.setInterval(() => {
|
||||
@@ -772,10 +1177,10 @@ onMounted(async () => {
|
||||
|
||||
// Auto-refresh channel tracks every few seconds
|
||||
channelRefreshTimerId = window.setInterval(() => {
|
||||
if (!channelBrowseLoading.value) {
|
||||
fetchChannelTracks({ silent: true })
|
||||
if (!channelPlaylistLoading.value && !channelStatusLoading.value) {
|
||||
refreshChannelData({ silent: true })
|
||||
}
|
||||
}, CHANNEL_TRACKS_REFRESH_INTERVAL)
|
||||
}, CHANNEL_REFRESH_INTERVAL)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -1326,6 +1731,13 @@ onUnmounted(() => {
|
||||
color: #9aa0a6;
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
margin-top: 16px;
|
||||
color: #9aa0a6;
|
||||
@@ -1364,6 +1776,36 @@ onUnmounted(() => {
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #9aa0a6;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.track-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1393,22 +1835,22 @@ onUnmounted(() => {
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.status-badge.downloading {
|
||||
.status-badge.caching {
|
||||
background: rgba(255, 193, 7, 0.15);
|
||||
border-color: rgba(255, 193, 7, 0.4);
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.status-badge.external {
|
||||
background: rgba(0, 212, 255, 0.12);
|
||||
border-color: rgba(0, 212, 255, 0.4);
|
||||
color: #00d4ff;
|
||||
.status-badge.failed {
|
||||
background: rgba(255, 87, 34, 0.18);
|
||||
border-color: rgba(255, 87, 34, 0.5);
|
||||
color: #ff7043;
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
background: rgba(255, 87, 34, 0.15);
|
||||
border-color: rgba(255, 87, 34, 0.4);
|
||||
color: #ff6d3a;
|
||||
background: rgba(0, 212, 255, 0.12);
|
||||
border-color: rgba(0, 212, 255, 0.4);
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
.track-metadata {
|
||||
@@ -1452,6 +1894,63 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inline-error {
|
||||
margin-top: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.inline-success {
|
||||
margin-top: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.formats-list {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: #9aa0a6;
|
||||
}
|
||||
|
||||
.format-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-weight: 600;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.history-meta {
|
||||
margin-top: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: #9aa0a6;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.audio-player-container {
|
||||
margin: 12px 0 24px;
|
||||
padding: 16px;
|
||||
|
||||
Reference in New Issue
Block a user