diff --git a/frontend/src/components/subscriptions/ArtistSection.vue b/frontend/src/components/subscriptions/ArtistSection.vue deleted file mode 100644 index 148d813..0000000 --- a/frontend/src/components/subscriptions/ArtistSection.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - - - diff --git a/frontend/src/components/subscriptions/SourceRow.vue b/frontend/src/components/subscriptions/SourceRow.vue index cb7a551..63f3bf4 100644 --- a/frontend/src/components/subscriptions/SourceRow.vue +++ b/frontend/src/components/subscriptions/SourceRow.vue @@ -1,25 +1,63 @@ diff --git a/frontend/src/views/SubscriptionsView.vue b/frontend/src/views/SubscriptionsView.vue index c5f2a18..03870d5 100644 --- a/frontend/src/views/SubscriptionsView.vue +++ b/frontend/src/views/SubscriptionsView.vue @@ -8,9 +8,13 @@ New artist - - {{ expandAll ? 'Collapse all' : 'Expand all' }} - + @@ -21,24 +25,121 @@ -
-

No subscriptions yet. Add your first artist.

+
+

No subscriptions yet. Add your first artist.

+

No subscriptions match "{{ search }}".

-
- -
+ + + + + + + + + + + + + + + { const raw = route.query.artist_id return raw == null ? null : Number(raw) }) -const expandAll = ref(false) -const openSections = ref(new Set()) -const showSourceDialog = ref(false) -const editingSource = ref(null) -const editingArtist = ref(null) -const showArtistDialog = ref(false) +const failureThreshold = computed(() => + importStore.settings?.download_failure_warning_threshold ?? 5 +) async function refresh() { await store.loadAll() await platformsStore.loadAll() - if (artistFilter.value != null) { - openSections.value = new Set([artistFilter.value]) - expandAll.value = false - } + if (!importStore.settings) await importStore.loadSettings() } -onMounted(refresh) +onMounted(() => { + refresh() + if (artistFilter.value != null) { + // Pre-expand the row that the deep-link refers to. + expanded.value = [`artist-${artistFilter.value}`] + } +}) watch(() => route.query.artist_id, refresh) +const headers = [ + { title: 'Subscription', key: 'name', sortable: true, align: 'start' }, + { title: 'Sources', key: 'sources_count', sortable: true, align: 'start', width: 110 }, + { title: 'Health', key: 'health', sortable: false, align: 'start', width: 80 }, + { title: 'Last activity',key: 'last_activity', sortable: true, align: 'start', width: 140 }, + { title: 'Actions', key: 'actions', sortable: false, align: 'end', width: 200 }, +] + const groups = computed(() => { const all = store.sourcesByArtistGrouped() - if (artistFilter.value == null) return all - return all.filter(g => g.artist.id === artistFilter.value) + return all.map(g => { + const worstSource = pickWorstSource(g.sources, failureThreshold.value) + const lastActivity = pickLastActivity(g.sources) + return { + key: `artist-${g.artist.id}`, + artist: g.artist, + sources: g.sources, + sources_count: g.sources.length, + worstSource, + lastActivity, + name: g.artist.name, // for sortable column + last_activity: lastActivity ?? '', // for sortable column + } + }) }) -function isOpen(artistId) { - return expandAll.value || openSections.value.has(artistId) +const filteredGroups = computed(() => { + let arr = groups.value + if (artistFilter.value != null) { + arr = arr.filter(g => g.artist.id === artistFilter.value) + } + const q = search.value?.trim().toLowerCase() + if (q) { + arr = arr.filter(g => + g.artist.name.toLowerCase().includes(q) + || g.sources.some(s => (s.url || '').toLowerCase().includes(q) + || (s.platform || '').toLowerCase().includes(q)) + ) + } + return arr +}) + +function pickLastActivity(sources) { + let max = null + for (const s of sources) { + if (s.last_checked_at && (!max || s.last_checked_at > max)) max = s.last_checked_at + } + return max } -function toggleSection(artistId) { - if (openSections.value.has(artistId)) openSections.value.delete(artistId) - else openSections.value.add(artistId) +function pickWorstSource(sources, threshold) { + // Health order (worst โ†’ best): critical, warning, healthy, unchecked. + // Picks the source with the worst level so the row's dot reflects the + // worst-case state. Within a level, the first is fine. + if (!sources || sources.length === 0) return null + function level(s) { + if (!s.last_checked_at) return 0 // unchecked + const f = s.consecutive_failures || 0 + if (f === 0) return 1 // healthy + if (f < threshold) return 2 // warning + return 3 // critical + } + return sources.reduce((worst, s) => + level(s) > level(worst) ? s : worst, + sources[0], + ) +} + +function formatRelative(iso) { + if (!iso) return 'Never' + const then = new Date(iso).getTime() + const diff = (Date.now() - then) / 1000 + if (diff < 60) return `${Math.floor(diff)}s ago` + if (diff < 3600) return `${Math.floor(diff / 60)}m ago` + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` + return `${Math.floor(diff / 86400)}d ago` +} + +function onRowClick(_evt, { item, internalItem }) { + // Toggle expansion on row click (in addition to the chevron). + const key = item.key + const idx = expanded.value.indexOf(key) + if (idx === -1) expanded.value = [...expanded.value, key] + else expanded.value = expanded.value.filter(k => k !== key) } function openAddSource(artist) { @@ -132,7 +321,6 @@ async function onSourceSaved() { function onArtistCreated(artist) { showArtistDialog.value = false - // Move into Add Source for the new artist immediately. openAddSource(artist) } @@ -158,6 +346,31 @@ async function onCheck(source) { } } } + +async function checkAll(group) { + let ok = 0 + let conflict = 0 + for (const s of group.sources) { + if (!s.enabled) continue + try { + await store.checkNow(s.id) + ok += 1 + } catch (e) { + if (e?.body?.download_event_id) conflict += 1 + } + } + const parts = [] + if (ok) parts.push(`${ok} queued`) + if (conflict) parts.push(`${conflict} already running`) + globalThis.window?.__fcToast?.({ + text: parts.join(', ') || 'Nothing to check (no enabled sources)', + type: 'info', + }) +} + +function anyChecking(sources) { + return sources.some(s => store.checkingIds.has(s.id)) +}