feat: add Qobuz playlist caching with versioning and infinite scroll UI

- Add qobuz_debug to .gitignore
- Bump PMOMusic version to 0.3.26
- Add .cargo/ copy in Dockerfile for registry config
- Implement infinite scroll in ServerDrawer.vue with IntersectionObserver and sentinel element
- Add source/source_version fields to playlists for cache invalidation
- Update pmocache and pmoplaylist DB schemas with versioning (SCHEMA_VERSION)
- Add Qobuz API metadata fetching and pagination for playlist tracks
- Implement album/playlist cache invalidation based on released_at/updated_at timestamps
- Refactor adapt_playlist_items_to_qobuz → adapt_items_to_qobuz with cleaner logic
- Add debug mode to save Qobuz API responses when QOBUZ_DEBUG_DIR is set
This commit is contained in:
2026-03-24 17:10:38 +01:00
parent 402c3399e4
commit 0137a4675f
17 changed files with 688 additions and 160 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch, reactive } from "vue";
import { ref, computed, watch, reactive, onMounted, onBeforeUnmount } from "vue";
import { useRouter } from "vue-router";
import {
X,
@@ -20,7 +20,6 @@ import type {
MediaServerSummary,
ContainerEntry,
} from "@/services/pmocontrol/types";
import type { BrowseState } from "@/composables/useMediaServers";
const props = defineProps<{
modelValue: boolean; // v-model pour contrôler l'ouverture
@@ -35,6 +34,10 @@ const {
allServers,
fetchServers,
browseContainer,
getBrowseCached,
loadMoreBrowse,
hasMore,
loadingMore,
currentPath,
setPath,
clearPath,
@@ -47,9 +50,51 @@ const router = useRouter();
// État de navigation
const currentServer = ref<MediaServerSummary | null>(null);
const browseData = ref<BrowseState | null>(null);
const currentContainerId = ref<string | null>(null);
const browseData = computed(() =>
currentServer.value && currentContainerId.value
? (getBrowseCached(currentServer.value.id, currentContainerId.value) ?? null)
: null,
);
const isLoading = ref(false);
// Infinite scroll
const sentinelRef = ref<HTMLElement | null>(null);
const drawerContentRef = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const canLoadMore = computed(() =>
currentServer.value && currentContainerId.value
? hasMore(currentServer.value.id, currentContainerId.value)
: false,
);
function setupObserver() {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && canLoadMore.value && !loadingMore.value) {
if (currentServer.value && currentContainerId.value) {
loadMoreBrowse(currentServer.value.id, currentContainerId.value);
}
}
},
{ root: drawerContentRef.value, threshold: 0.1 },
);
if (sentinelRef.value) observer.observe(sentinelRef.value);
}
onMounted(() => setupObserver());
onBeforeUnmount(() => observer?.disconnect());
watch(sentinelRef, (el) => {
if (el) setupObserver();
});
watch(drawerContentRef, (el) => {
if (el) setupObserver();
});
// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert)
const openMenuId = ref<string | null>(null);
@@ -85,7 +130,7 @@ watch(
} else {
// Reset navigation quand on ferme
currentServer.value = null;
browseData.value = null;
currentContainerId.value = null;
clearPath();
closeMenu();
imageStates.clear();
@@ -143,11 +188,12 @@ async function handleServerClick(server: MediaServerSummary) {
try {
// Browse racine (containerId = "0")
browseData.value = await browseContainer(server.id, "0");
await browseContainer(server.id, "0");
currentContainerId.value = "0";
setPath([{ id: "0", title: server.friendly_name }]);
} catch (error) {
console.error("[ServerDrawer] Erreur browse racine:", error);
browseData.value = null;
currentContainerId.value = null;
} finally {
isLoading.value = false;
}
@@ -155,7 +201,7 @@ async function handleServerClick(server: MediaServerSummary) {
function goBack() {
currentServer.value = null;
browseData.value = null;
currentContainerId.value = null;
clearPath();
}
@@ -164,10 +210,8 @@ async function handleContainerClick(item: ContainerEntry) {
isLoading.value = true;
try {
browseData.value = await browseContainer(
currentServer.value.id,
item.id,
);
await browseContainer(currentServer.value.id, item.id);
currentContainerId.value = item.id;
setPath([...currentPath.value, { id: item.id, title: item.title }]);
} catch (error) {
console.error("[ServerDrawer] Erreur browse container:", error);
@@ -185,10 +229,8 @@ async function handleBreadcrumbClick(index: number) {
isLoading.value = true;
try {
browseData.value = await browseContainer(
currentServer.value.id,
targetCrumb.id,
);
await browseContainer(currentServer.value.id, targetCrumb.id);
currentContainerId.value = targetCrumb.id;
setPath(currentPath.value.slice(0, index + 1));
} catch (error) {
console.error("[ServerDrawer] Erreur breadcrumb navigation:", error);
@@ -386,7 +428,7 @@ function handleSettingsClick() {
</nav>
<!-- Contenu -->
<div class="drawer-content">
<div ref="drawerContentRef" class="drawer-content">
<!-- Liste des serveurs -->
<div v-if="!isNavigating">
<!-- Servers online -->
@@ -627,8 +669,16 @@ function handleSettingsClick() {
</li>
</ul>
<!-- Sentinel infinite scroll -->
<div v-if="canLoadMore" ref="sentinelRef" class="scroll-sentinel" />
<!-- Spinner load more -->
<div v-if="loadingMore" class="load-more-spinner">
<div class="spinner"></div>
</div>
<!-- Vide -->
<div v-else class="empty-servers">
<div v-else-if="!browseData" class="empty-servers">
<Folder :size="48" />
<p>Dossier vide</p>
</div>
@@ -1257,6 +1307,17 @@ function handleSettingsClick() {
transform: translateY(0);
}
.scroll-sentinel {
height: 1px;
}
.load-more-spinner {
display: flex;
justify-content: center;
padding: var(--spacing-md);
color: var(--color-text-secondary);
}
/* Animations */
.backdrop-enter-active {
transition: opacity 0.3s ease-out;