mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-04-24 16:53:37 +00:00
Add Plex integration icons and improve authentication flow
- Create IconPlex with play button symbol for Plex login button - Create IconServer for server information display - Create IconSync for library sync operations - Replace inline SVGs with icon components in PlexAuthButton - Replace inline SVGs with icon components in PlexServerInfo - Add MissingPlexAuthPage for better auth error handling - Update routes to redirect missing Plex auth to dedicated page - Refactor usePlexApi and usePlexAuth for better composable patterns - Remove deprecated usePlexLibraries composable - Improve PlexLibraryModal and PlexLibraryStats components - Clean up Plex-related helper utilities
This commit is contained in:
@@ -9,18 +9,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="signin-container">
|
<div class="signin-container">
|
||||||
<button @click="handleAuth" :disabled="loading" class="plex-signin-btn">
|
<button @click="handleAuth" :disabled="loading" class="plex-signin-btn">
|
||||||
<svg
|
|
||||||
v-if="!loading"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 256 256"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M128 0C57.3 0 0 57.3 0 128s57.3 128 128 128 128-57.3 128-128S198.7 0 128 0zm57.7 128.7l-48 48c-.4.4-.9.7-1.4.9-.5.2-1.1.4-1.6.4s-1.1-.1-1.6-.4c-.5-.2-1-.5-1.4-.9l-48-48c-1.6-1.6-1.6-4.1 0-5.7 1.6-1.6 4.1-1.6 5.7 0l41.1 41.1V80c0-2.2 1.8-4 4-4s4 1.8 4 4v84.1l41.1-41.1c1.6-1.6 4.1-1.6 5.7 0 .8.8 1.2 1.8 1.2 2.8s-.4 2.1-1.2 2.9z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{{ loading ? "Connecting..." : "Sign in with Plex" }}
|
{{ loading ? "Connecting..." : "Sign in with Plex" }}
|
||||||
|
|
||||||
|
<IconPlex v-if="!loading" class="plex-icon" />
|
||||||
</button>
|
</button>
|
||||||
<p class="popup-note">A popup window will open for authentication</p>
|
<p class="popup-note">A popup window will open for authentication</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,6 +21,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { usePlexAuth } from "@/composables/usePlexAuth";
|
import { usePlexAuth } from "@/composables/usePlexAuth";
|
||||||
import IconInfo from "@/icons/IconInfo.vue";
|
import IconInfo from "@/icons/IconInfo.vue";
|
||||||
|
import IconPlex from "@/icons/IconPlex.vue";
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
authSuccess: [token: string];
|
authSuccess: [token: string];
|
||||||
@@ -134,10 +126,12 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
.plex-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 22px;
|
--size: 24px;
|
||||||
height: 22px;
|
width: var(--size);
|
||||||
|
height: var(--size);
|
||||||
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="plex-library-item"
|
class="plex-library-item"
|
||||||
>
|
>
|
||||||
<figure class="item-poster">
|
<figure :class="`item-poster ${item.type}`">
|
||||||
<img
|
<img
|
||||||
v-if="item.poster"
|
v-if="item.poster"
|
||||||
:src="item.poster"
|
:src="item.poster"
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style style="scss" scoped>
|
||||||
.plex-library-item {
|
.plex-library-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -144,6 +144,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #333;
|
background: #333;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
|
&.music {
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.poster-image {
|
.poster-image {
|
||||||
|
|||||||
@@ -21,19 +21,33 @@
|
|||||||
<div class="library-stats-overview">
|
<div class="library-stats-overview">
|
||||||
<div class="overview-stat">
|
<div class="overview-stat">
|
||||||
<span class="overview-label">Total Items</span>
|
<span class="overview-label">Total Items</span>
|
||||||
<span class="overview-value">{{ details.total }}</span>
|
<span class="overview-value">{{
|
||||||
|
formatNumber(details.total)
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="overview-stat" v-if="libraryType === 'shows'">
|
|
||||||
|
<div class="overview-stat" v-if="libraryType === 'tv shows'">
|
||||||
|
<span class="overview-label">Seasons</span>
|
||||||
|
<span class="overview-value">{{
|
||||||
|
formatNumber(details?.childCount)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="overview-stat" v-if="libraryType === 'tv shows'">
|
||||||
<span class="overview-label">Episodes</span>
|
<span class="overview-label">Episodes</span>
|
||||||
<span class="overview-value">{{ details.totalEpisodes }}</span>
|
<span class="overview-value">{{
|
||||||
|
formatNumber(details?.leafCount)
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overview-stat" v-if="libraryType === 'music'">
|
<div class="overview-stat" v-if="libraryType === 'music'">
|
||||||
<span class="overview-label">Tracks</span>
|
<span class="overview-label">Tracks</span>
|
||||||
<span class="overview-value">{{ details.totalTracks }}</span>
|
<span class="overview-value">{{ details?.totalTracks }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="overview-stat">
|
<div class="overview-stat">
|
||||||
<span class="overview-label">Duration</span>
|
<span class="overview-label">Duration</span>
|
||||||
<span class="overview-value">{{ details.totalDuration }}</span>
|
<span class="overview-value">{{
|
||||||
|
convertSecondsToHumanReadable(details?.duration / 1000)
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -42,10 +56,12 @@
|
|||||||
<h4 class="section-title">Recently Added</h4>
|
<h4 class="section-title">Recently Added</h4>
|
||||||
<div class="recent-items-grid">
|
<div class="recent-items-grid">
|
||||||
<PlexLibraryItem
|
<PlexLibraryItem
|
||||||
v-for="(item, index) in details.recentlyAdded"
|
v-for="(item, index) in recentlyAdded"
|
||||||
:key="index"
|
:key="index"
|
||||||
:item="item"
|
:item="item"
|
||||||
:show-extras="libraryType === 'music' || libraryType === 'shows'"
|
:show-extras="
|
||||||
|
libraryType === 'music' || libraryType === 'tv shows'
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,41 +94,70 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount } from "vue";
|
import { computed, onMounted, onBeforeUnmount, ref } from "vue";
|
||||||
import IconClose from "@/icons/IconClose.vue";
|
import IconClose from "@/icons/IconClose.vue";
|
||||||
import IconMovie from "@/icons/IconMovie.vue";
|
import IconMovie from "@/icons/IconMovie.vue";
|
||||||
import IconShow from "@/icons/IconShow.vue";
|
import IconShow from "@/icons/IconShow.vue";
|
||||||
import IconMusic from "@/icons/IconMusic.vue";
|
import IconMusic from "@/icons/IconMusic.vue";
|
||||||
import PlexLibraryItem from "@/components/plex/PlexLibraryItem.vue";
|
import PlexLibraryItem from "@/components/plex/PlexLibraryItem.vue";
|
||||||
import { getLibraryTitle } from "@/utils/plexHelpers";
|
import { getLibraryTitle } from "@/utils/plexHelpers";
|
||||||
|
import { plexRecentlyAddedInLibrary } from "@/api";
|
||||||
|
import { processLibraryItem } from "@/utils/plexHelpers";
|
||||||
|
import { formatNumber, convertSecondsToHumanReadable } from "@/utils";
|
||||||
|
import { usePlexAuth } from "@/composables/usePlexAuth";
|
||||||
|
|
||||||
|
const { getPlexAuthCookie } = usePlexAuth();
|
||||||
|
const authToken = getPlexAuthCookie();
|
||||||
|
|
||||||
interface LibraryDetails {
|
interface LibraryDetails {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
total: number;
|
total: number;
|
||||||
recentlyAdded: any[];
|
childCount?: number;
|
||||||
genres: { name: string; count: number }[];
|
leafCount?: number;
|
||||||
totalDuration: string;
|
duration: number;
|
||||||
totalEpisodes?: number;
|
genres: Array<{
|
||||||
totalTracks?: number;
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryType: string;
|
libraryType: string;
|
||||||
details: LibraryDetails;
|
details: LibraryDetails;
|
||||||
|
serverUrl: string;
|
||||||
|
serverMachineId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
let recentlyAdded = ref([]);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "close"): void;
|
(e: "close"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const libraryIconComponent = computed(() => {
|
const libraryIconComponent = computed(() => {
|
||||||
if (props.libraryType === "movies") return IconMovie;
|
if (props.libraryType === "movies") return IconMovie;
|
||||||
if (props.libraryType === "shows") return IconShow;
|
if (props.libraryType === "tv shows") return IconShow;
|
||||||
if (props.libraryType === "music") return IconMusic;
|
if (props.libraryType === "music") return IconMusic;
|
||||||
return IconMovie;
|
return IconMovie;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function fetchRecentlyAdded() {
|
||||||
|
plexRecentlyAddedInLibrary(props.details.id).then(added => {
|
||||||
|
recentlyAdded.value = added?.MediaContainer?.Metadata.map(el =>
|
||||||
|
processLibraryItem(
|
||||||
|
el,
|
||||||
|
props.libraryType,
|
||||||
|
authToken,
|
||||||
|
props.serverUrl,
|
||||||
|
props.serverMachineId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function checkEventForEscapeKey(event: KeyboardEvent) {
|
function checkEventForEscapeKey(event: KeyboardEvent) {
|
||||||
if (event.key !== "Escape") return;
|
if (event.key !== "Escape") return;
|
||||||
emit("close");
|
emit("close");
|
||||||
@@ -120,12 +165,18 @@
|
|||||||
|
|
||||||
window.addEventListener("keyup", checkEventForEscapeKey);
|
window.addEventListener("keyup", checkEventForEscapeKey);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchRecentlyAdded();
|
||||||
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener("keyup", checkEventForEscapeKey);
|
window.removeEventListener("keyup", checkEventForEscapeKey);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/media-queries.scss";
|
||||||
|
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -139,6 +190,10 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-modal-content {
|
.library-modal-content {
|
||||||
@@ -150,6 +205,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: unset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-modal-header {
|
.library-modal-header {
|
||||||
@@ -198,12 +258,16 @@
|
|||||||
border: none;
|
border: none;
|
||||||
color: #888;
|
color: #888;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 8px;
|
padding: 0.5rem;
|
||||||
height: var(--size);
|
height: var(--size);
|
||||||
width: var(--size);
|
width: var(--size);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
fill: white;
|
fill: white;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
margin: auto 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
|
|||||||
@@ -5,18 +5,18 @@
|
|||||||
:key="stat.key"
|
:key="stat.key"
|
||||||
class="stat-card"
|
class="stat-card"
|
||||||
:class="{
|
:class="{
|
||||||
disabled: stat.value === 0 || loading,
|
disabled: stat.value === undefined || stat.value === 0 || loading,
|
||||||
unclickable: !!!stat.clickable
|
unclickable: !!!stat.clickable
|
||||||
}"
|
}"
|
||||||
@click="
|
@click="
|
||||||
stat.clickable && stat.value > 0 && !loading && handleClick(stat.key)
|
stat.clickable && stat.value?.total > 0 && !loading && handleClick(stat.key)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div class="stat-icon">
|
<div class="stat-icon">
|
||||||
<component :is="stat.icon" />
|
<component :is="stat.icon" />
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-value" v-if="!loading">{{ stat.value }}</div>
|
<div class="stat-value" v-if="!loading">{{ formatNumber(stat.value?.total) }}</div>
|
||||||
<div class="stat-value loading-dots" v-else>...</div>
|
<div class="stat-value loading-dots" v-else>...</div>
|
||||||
<div class="stat-label">{{ stat.label }}</div>
|
<div class="stat-label">{{ stat.label }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,15 +26,24 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
import { formatNumber } from '@/utils'
|
||||||
import IconMovie from "@/icons/IconMovie.vue";
|
import IconMovie from "@/icons/IconMovie.vue";
|
||||||
import IconShow from "@/icons/IconShow.vue";
|
import IconShow from "@/icons/IconShow.vue";
|
||||||
import IconMusic from "@/icons/IconMusic.vue";
|
import IconMusic from "@/icons/IconMusic.vue";
|
||||||
import IconClock from "@/icons/IconClock.vue";
|
import IconClock from "@/icons/IconClock.vue";
|
||||||
|
|
||||||
|
interface LibraryStat {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
total: number;
|
||||||
|
childCount?: number;
|
||||||
|
leafCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
movies: number;
|
movies: LibraryStat;
|
||||||
shows: number;
|
shows: LibraryStat;
|
||||||
music: number;
|
music: LibraryStat;
|
||||||
watchtime: number;
|
watchtime: number;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
@@ -54,7 +63,7 @@
|
|||||||
clickable: true
|
clickable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "shows",
|
key: "tv shows",
|
||||||
icon: IconShow,
|
icon: IconShow,
|
||||||
value: props.shows,
|
value: props.shows,
|
||||||
label: "TV Shows",
|
label: "TV Shows",
|
||||||
|
|||||||
@@ -3,37 +3,14 @@
|
|||||||
<div class="plex-details">
|
<div class="plex-details">
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-label">
|
<span class="detail-label">
|
||||||
<svg
|
<IconServer class="label-icon" />
|
||||||
width="16"
|
Plex server name
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
|
|
||||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
|
|
||||||
</svg>
|
|
||||||
Server
|
|
||||||
</span>
|
</span>
|
||||||
<span class="detail-value">{{ serverName || "Unknown" }}</span>
|
<span class="detail-value">{{ serverName || "Unknown" }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-label">
|
<span class="detail-label">
|
||||||
<svg
|
<IconSync class="label-icon" />
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<polyline points="23 4 23 10 17 10"></polyline>
|
|
||||||
<polyline points="1 20 1 14 7 14"></polyline>
|
|
||||||
<path
|
|
||||||
d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Last Sync
|
Last Sync
|
||||||
</span>
|
</span>
|
||||||
<span class="detail-value">{{ lastSync || "Never" }}</span>
|
<span class="detail-value">{{ lastSync || "Never" }}</span>
|
||||||
@@ -42,21 +19,7 @@
|
|||||||
|
|
||||||
<div class="plex-actions">
|
<div class="plex-actions">
|
||||||
<seasoned-button @click="$emit('sync')" :disabled="syncing">
|
<seasoned-button @click="$emit('sync')" :disabled="syncing">
|
||||||
<svg
|
<IconSync v-if="!syncing" class="button-icon" />
|
||||||
v-if="!syncing"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<polyline points="23 4 23 10 17 10"></polyline>
|
|
||||||
<polyline points="1 20 1 14 7 14"></polyline>
|
|
||||||
<path
|
|
||||||
d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
{{ syncing ? "Syncing..." : "Sync Library" }}
|
{{ syncing ? "Syncing..." : "Sync Library" }}
|
||||||
</seasoned-button>
|
</seasoned-button>
|
||||||
<seasoned-button @click="$emit('unlink')">
|
<seasoned-button @click="$emit('unlink')">
|
||||||
@@ -68,6 +31,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
|
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
|
||||||
|
import IconServer from "@/icons/IconServer.vue";
|
||||||
|
import IconSync from "@/icons/IconSync.vue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverName: string;
|
serverName: string;
|
||||||
@@ -120,6 +85,11 @@
|
|||||||
color: var(--text-color-60);
|
color: var(--text-color-60);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.label-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-value {
|
.detail-value {
|
||||||
@@ -147,6 +117,11 @@
|
|||||||
svg {
|
svg {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,16 +1,57 @@
|
|||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
import { API_HOSTNAME } from "../api";
|
||||||
|
|
||||||
// Shared constants - generated once and reused
|
// Shared constants - generated once and reused
|
||||||
export const CLIENT_IDENTIFIER = `seasoned-plex-app-${Math.random().toString(36).substring(7)}`;
|
export const CLIENT_IDENTIFIER = `seasoned-plex-app-${Math.random().toString(36).substring(7)}`;
|
||||||
export const APP_NAME = window.location.hostname;
|
export const APP_NAME = window.location.hostname;
|
||||||
|
|
||||||
export function usePlexApi() {
|
async function fetchPlexServers(authToken: string) {
|
||||||
const plexServerUrl = ref("");
|
try {
|
||||||
|
const url =
|
||||||
|
"https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1";
|
||||||
|
const options = {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"X-Plex-Token": authToken,
|
||||||
|
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch Plex servers");
|
||||||
|
}
|
||||||
|
|
||||||
|
const servers = await response.json();
|
||||||
|
const ownedServer = servers.find(
|
||||||
|
(s: any) => s.owned && s.provides === "server"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ownedServer) {
|
||||||
|
const connection =
|
||||||
|
ownedServer.connections?.find((c: any) => c.local === false) ||
|
||||||
|
ownedServer.connections?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: ownedServer.name,
|
||||||
|
url: connection?.uri,
|
||||||
|
machineIdentifier: ownedServer.clientIdentifier
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[PlexAPI] Error fetching Plex servers:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch Plex user data
|
|
||||||
async function fetchPlexUserData(authToken: string) {
|
async function fetchPlexUserData(authToken: string) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("https://plex.tv/api/v2/user", {
|
const url = "https://plex.tv/api/v2/user";
|
||||||
|
const options = {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
@@ -18,7 +59,9 @@ export function usePlexApi() {
|
|||||||
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER,
|
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER,
|
||||||
"X-Plex-Token": authToken
|
"X-Plex-Token": authToken
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to fetch Plex user info");
|
throw new Error("Failed to fetch Plex user info");
|
||||||
@@ -60,7 +103,6 @@ export function usePlexApi() {
|
|||||||
created_at: new Date().toISOString()
|
created_at: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem("plex_user_data", JSON.stringify(userData));
|
|
||||||
return userData;
|
return userData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[PlexAPI] Error fetching Plex user data:", error);
|
console.error("[PlexAPI] Error fetching Plex user data:", error);
|
||||||
@@ -68,133 +110,16 @@ export function usePlexApi() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch Plex servers
|
|
||||||
async function fetchPlexServers(authToken: string) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
"https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1",
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
accept: "application/json",
|
|
||||||
"X-Plex-Token": authToken,
|
|
||||||
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch Plex servers");
|
|
||||||
}
|
|
||||||
|
|
||||||
const servers = await response.json();
|
|
||||||
const ownedServer = servers.find(
|
|
||||||
(s: any) => s.owned && s.provides === "server"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (ownedServer) {
|
|
||||||
const connection =
|
|
||||||
ownedServer.connections?.find((c: any) => c.local === false) ||
|
|
||||||
ownedServer.connections?.[0];
|
|
||||||
if (connection) {
|
|
||||||
plexServerUrl.value = connection.uri;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name: ownedServer.name,
|
|
||||||
url: plexServerUrl.value,
|
|
||||||
machineIdentifier: ownedServer.clientIdentifier
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[PlexAPI] Error fetching Plex servers:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch library sections
|
|
||||||
async function fetchLibrarySections(authToken: string, serverUrl: string) {
|
|
||||||
if (!serverUrl) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${serverUrl}/library/sections`, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
accept: "application/json",
|
|
||||||
"X-Plex-Token": authToken
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch library sections");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data.MediaContainer?.Directory || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[PlexAPI] Error fetching library sections:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch library details
|
// Fetch library details
|
||||||
async function fetchLibraryDetails(
|
async function fetchLibraryDetails() {
|
||||||
authToken: string,
|
|
||||||
serverUrl: string,
|
|
||||||
sectionKey: string
|
|
||||||
) {
|
|
||||||
if (!serverUrl) return null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch all items
|
const url = `${API_HOSTNAME}/api/v2/plex/library`;
|
||||||
const allResponse = await fetch(
|
const options: RequestInit = { credentials: "include" };
|
||||||
`${serverUrl}/library/sections/${sectionKey}/all`,
|
return await fetch(url, options).then(resp => resp.json());
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
accept: "application/json",
|
|
||||||
"X-Plex-Token": authToken
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!allResponse.ok) throw new Error("Failed to fetch all items");
|
|
||||||
const allData = await allResponse.json();
|
|
||||||
|
|
||||||
// Fetch recently added
|
|
||||||
const size = 20;
|
|
||||||
const recentResponse = await fetch(
|
|
||||||
`${serverUrl}/library/sections/${sectionKey}/recentlyAdded?X-Plex-Container-Start=0&X-Plex-Container-Size=${size}`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
accept: "application/json",
|
|
||||||
"X-Plex-Token": authToken
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!recentResponse.ok) throw new Error("Failed to fetch recently added");
|
|
||||||
const recentData = await recentResponse.json();
|
|
||||||
|
|
||||||
return {
|
|
||||||
all: allData,
|
|
||||||
recent: recentData,
|
|
||||||
metadata: allData.MediaContainer?.Metadata || [],
|
|
||||||
recentMetadata: recentData.MediaContainer?.Metadata || []
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[PlexAPI] Error fetching library details:", error);
|
console.error("[PlexAPI] error fetching library:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
export { fetchPlexServers, fetchPlexUserData, fetchLibraryDetails };
|
||||||
plexServerUrl,
|
|
||||||
fetchPlexUserData,
|
|
||||||
fetchPlexServers,
|
|
||||||
fetchLibrarySections,
|
|
||||||
fetchLibraryDetails
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,15 +9,17 @@ export function usePlexAuth() {
|
|||||||
// Generate a PIN for Plex OAuth
|
// Generate a PIN for Plex OAuth
|
||||||
async function generatePlexPin() {
|
async function generatePlexPin() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("https://plex.tv/api/v2/pins?strong=true", {
|
const url = "https://plex.tv/api/v2/pins?strong=true";
|
||||||
|
const options = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
"X-Plex-Product": APP_NAME,
|
"X-Plex-Product": APP_NAME,
|
||||||
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
|
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, options);
|
||||||
if (!response.ok) throw new Error("Failed to generate PIN");
|
if (!response.ok) throw new Error("Failed to generate PIN");
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return { id: data.id, code: data.code };
|
return { id: data.id, code: data.code };
|
||||||
@@ -30,15 +32,15 @@ export function usePlexAuth() {
|
|||||||
// Check PIN status
|
// Check PIN status
|
||||||
async function checkPin(pinId: number, pinCode: string) {
|
async function checkPin(pinId: number, pinCode: string) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const url = `https://plex.tv/api/v2/pins/${pinId}?code=${pinCode}`;
|
||||||
`https://plex.tv/api/v2/pins/${pinId}?code=${pinCode}`,
|
const options = {
|
||||||
{
|
|
||||||
headers: {
|
headers: {
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
|
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
);
|
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -93,9 +95,10 @@ export function usePlexAuth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get cookie
|
// Get cookie
|
||||||
function getCookie(name: string): string | null {
|
function getPlexAuthCookie(): string | null {
|
||||||
|
const key = "plex_auth_token";
|
||||||
const value = `; ${document.cookie}`;
|
const value = `; ${document.cookie}`;
|
||||||
const parts = value.split(`; ${name}=`);
|
const parts = value.split(`; ${key}=`);
|
||||||
if (parts.length === 2) {
|
if (parts.length === 2) {
|
||||||
return parts.pop()?.split(";").shift() || null;
|
return parts.pop()?.split(";").shift() || null;
|
||||||
}
|
}
|
||||||
@@ -171,9 +174,10 @@ export function usePlexAuth() {
|
|||||||
if (plexPopup.value && plexPopup.value.closed) {
|
if (plexPopup.value && plexPopup.value.closed) {
|
||||||
clearInterval(popupChecker);
|
clearInterval(popupChecker);
|
||||||
stopPolling();
|
stopPolling();
|
||||||
|
|
||||||
if (loading.value) {
|
if (loading.value) {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
onError("Plex authentication window was closed");
|
// onError("Plex authentication window was closed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
@@ -190,7 +194,7 @@ export function usePlexAuth() {
|
|||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
setPlexAuthCookie,
|
setPlexAuthCookie,
|
||||||
getCookie,
|
getPlexAuthCookie,
|
||||||
openAuthPopup,
|
openAuthPopup,
|
||||||
cleanup
|
cleanup
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
import {
|
|
||||||
processLibraryItem,
|
|
||||||
calculateGenreStats,
|
|
||||||
calculateDuration
|
|
||||||
} from "@/utils/plexHelpers";
|
|
||||||
|
|
||||||
export function usePlexLibraries() {
|
|
||||||
async function loadLibraries(
|
|
||||||
sections: any[],
|
|
||||||
authToken: string,
|
|
||||||
serverUrl: string,
|
|
||||||
machineIdentifier: string,
|
|
||||||
username: string,
|
|
||||||
fetchLibraryDetailsFn: any
|
|
||||||
) {
|
|
||||||
// Reset stats
|
|
||||||
const stats = { movies: 0, shows: 0, music: 0, watchtime: 0 };
|
|
||||||
const details: any = {
|
|
||||||
movies: {
|
|
||||||
total: 0,
|
|
||||||
recentlyAdded: [],
|
|
||||||
genres: [],
|
|
||||||
totalDuration: "0 hours"
|
|
||||||
},
|
|
||||||
shows: {
|
|
||||||
total: 0,
|
|
||||||
recentlyAdded: [],
|
|
||||||
genres: [],
|
|
||||||
totalEpisodes: 0,
|
|
||||||
totalDuration: "0 hours"
|
|
||||||
},
|
|
||||||
music: { total: 0, recentlyAdded: [], genres: [], totalTracks: 0 }
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const section of sections) {
|
|
||||||
const { type } = section;
|
|
||||||
const { key } = section;
|
|
||||||
|
|
||||||
if (type === "movie") {
|
|
||||||
await processLibrarySection(
|
|
||||||
authToken,
|
|
||||||
serverUrl,
|
|
||||||
machineIdentifier,
|
|
||||||
key,
|
|
||||||
"movies",
|
|
||||||
stats,
|
|
||||||
details,
|
|
||||||
fetchLibraryDetailsFn
|
|
||||||
);
|
|
||||||
} else if (type === "show") {
|
|
||||||
await processLibrarySection(
|
|
||||||
authToken,
|
|
||||||
serverUrl,
|
|
||||||
machineIdentifier,
|
|
||||||
key,
|
|
||||||
"shows",
|
|
||||||
stats,
|
|
||||||
details,
|
|
||||||
fetchLibraryDetailsFn
|
|
||||||
);
|
|
||||||
} else if (type === "artist") {
|
|
||||||
await processLibrarySection(
|
|
||||||
authToken,
|
|
||||||
serverUrl,
|
|
||||||
machineIdentifier,
|
|
||||||
key,
|
|
||||||
"music",
|
|
||||||
stats,
|
|
||||||
details,
|
|
||||||
fetchLibraryDetailsFn
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate watchtime from Tautulli if username provided
|
|
||||||
if (username) {
|
|
||||||
try {
|
|
||||||
const TAUTULLI_API_KEY = "28494032b47542278fe76c6ccd1f0619";
|
|
||||||
const TAUTULLI_BASE_URL = "http://plex.schleppe:8181/api/v2";
|
|
||||||
const url = `${TAUTULLI_BASE_URL}?apikey=${TAUTULLI_API_KEY}&cmd=get_history&user=${encodeURIComponent(
|
|
||||||
username
|
|
||||||
)}&length=8000`;
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
const history = data.response?.data?.data || [];
|
|
||||||
const totalMs = history.reduce(
|
|
||||||
(sum: number, item: any) => sum + (item.duration || 0) * 1000,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
stats.watchtime = Math.round(totalMs / (1000 * 60 * 60));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[PlexLibraries] Error fetching watchtime:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { stats, details };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[PlexLibraries] Error loading libraries:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processLibrarySection(
|
|
||||||
authToken: string,
|
|
||||||
serverUrl: string,
|
|
||||||
machineIdentifier: string,
|
|
||||||
sectionKey: string,
|
|
||||||
libraryType: string,
|
|
||||||
stats: any,
|
|
||||||
details: any,
|
|
||||||
fetchLibraryDetailsFn: any
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const data = await fetchLibraryDetailsFn(
|
|
||||||
authToken,
|
|
||||||
serverUrl,
|
|
||||||
sectionKey
|
|
||||||
);
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
const totalCount = data.all.MediaContainer?.size || 0;
|
|
||||||
|
|
||||||
// Update stats
|
|
||||||
if (libraryType === "movies") {
|
|
||||||
stats.movies += totalCount;
|
|
||||||
} else if (libraryType === "shows") {
|
|
||||||
stats.shows += totalCount;
|
|
||||||
} else if (libraryType === "music") {
|
|
||||||
stats.music += totalCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process recently added items
|
|
||||||
const recentItems = data.recentMetadata.map((item: any) =>
|
|
||||||
processLibraryItem(
|
|
||||||
item,
|
|
||||||
libraryType,
|
|
||||||
authToken,
|
|
||||||
serverUrl,
|
|
||||||
machineIdentifier
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate stats
|
|
||||||
const genres = calculateGenreStats(data.metadata);
|
|
||||||
const durations = calculateDuration(data.metadata, libraryType);
|
|
||||||
|
|
||||||
// Update library details
|
|
||||||
details[libraryType] = {
|
|
||||||
total: totalCount,
|
|
||||||
recentlyAdded: recentItems,
|
|
||||||
genres,
|
|
||||||
totalDuration: durations.totalDuration,
|
|
||||||
...(libraryType === "shows" && {
|
|
||||||
totalEpisodes: durations.totalEpisodes
|
|
||||||
}),
|
|
||||||
...(libraryType === "music" && { totalTracks: durations.totalTracks })
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[PlexLibraries] Error processing ${libraryType}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
loadLibraries
|
|
||||||
};
|
|
||||||
}
|
|
||||||
10
src/icons/IconPlex.vue
Normal file
10
src/icons/IconPlex.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<svg viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M16 2c-7.732 0-14 6.268-14 14s6.268 14 14 14 14-6.268 14-14-6.268-14-14-14zM16 28c-6.627 0-12-5.373-12-12s5.373-12 12-12c6.627 0 12 5.373 12 12s-5.373 12-12 12z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M13.333 10.667c-0.368 0-0.667 0.299-0.667 0.667v9.333c0 0.245 0.135 0.469 0.349 0.585 0.215 0.117 0.477 0.104 0.683-0.032l6.667-4.667c0.188-0.131 0.301-0.349 0.301-0.583s-0.113-0.452-0.301-0.583l-6.667-4.667c-0.109-0.076-0.239-0.115-0.365-0.115zM14.667 13.115l4.448 3.115-4.448 3.115v-6.229z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
17
src/icons/IconServer.vue
Normal file
17
src/icons/IconServer.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<svg viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M23 10V6c0-0.4719-0.1656-0.9094-0.4406-1.2531v0l-3.6688-4.5594C18.7969 0.0687 18.6531 0 18.5 0h-13C5.35 0 5.2062 0.0687 5.1094 0.1875L1.4406 4.75C1.1656 5.0906 1 5.5281 1 6v4c0 0.5969 0.2625 1.1344 0.6781 1.5C1.2625 11.8656 1 12.4031 1 13v2c0 0.5969 0.2625 1.1344 0.6781 1.5C1.2625 16.8656 1 17.4031 1 18v4c0 1.1031 0.8969 2 2 2h18c1.1031 0 2-0.8969 2-2v-4c0-0.5969-0.2625-1.1344-0.6781-1.5C22.7375 16.1344 23 15.5969 23 15v-2c0-0.5969-0.2625-1.1344-0.6781-1.5C22.7375 11.1344 23 10.5969 23 10zM5.7406 1h12.5219l2.4125 3H3.325zM21 22H3l-31e-4-4c0 0 0 0 31e-4 0v-1h18zM21.0031 15c0 0-31e-4 0 0 0L21 16H3v-1l-31e-4-2c0 0 0 0 31e-4 0v-1h18v1zM3 11V6h18v5z"
|
||||||
|
/>
|
||||||
|
<rect width="3" height="1.000008" x="16.999992" y="7.999992" />
|
||||||
|
<rect width="1.000008" height="1.000008" x="15" y="7.999992" />
|
||||||
|
<rect width="3" height="1.000008" x="16.999992" y="13.000008" />
|
||||||
|
<rect width="1.000008" height="1.000008" x="15" y="13.000008" />
|
||||||
|
<rect width="1.000008" height="1.000008" x="4.000008" y="18" />
|
||||||
|
<rect width="1.000008" height="1.000008" x="6" y="18" />
|
||||||
|
<rect width="1.000008" height="1.000008" x="7.999992" y="18" />
|
||||||
|
<rect width="1.000008" height="1.000008" x="10.000008" y="18" />
|
||||||
|
<rect width="3" height="1.000008" x="16.999992" y="19.999992" />
|
||||||
|
<rect width="1.000008" height="1.000008" x="15" y="19.999992" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
16
src/icons/IconSync.vue
Normal file
16
src/icons/IconSync.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="23 4 23 10 17 10"></polyline>
|
||||||
|
<polyline points="1 20 1 14 7 14"></polyline>
|
||||||
|
<path
|
||||||
|
d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -16,12 +16,13 @@ export interface CookieOptions {
|
|||||||
/**
|
/**
|
||||||
* Read a cookie value.
|
* Read a cookie value.
|
||||||
*/
|
*/
|
||||||
export function getCookie(name: string): string | null {
|
export function getAuthorizationCookie(): string | null {
|
||||||
|
const key = 'authorization';
|
||||||
const array = document.cookie.split(";");
|
const array = document.cookie.split(";");
|
||||||
let match = null;
|
let match = null;
|
||||||
|
|
||||||
array.forEach((item: string) => {
|
array.forEach((item: string) => {
|
||||||
const query = `${name}=`;
|
const query = `${key}=`;
|
||||||
if (!item.trim().startsWith(query)) return;
|
if (!item.trim().startsWith(query)) return;
|
||||||
match = item.trim().substring(query.length);
|
match = item.trim().substring(query.length);
|
||||||
});
|
});
|
||||||
@@ -132,7 +133,7 @@ const userModule: Module<UserState, RootState> = {
|
|||||||
/* ── Actions ─────────────────────────────────────────────────── */
|
/* ── Actions ─────────────────────────────────────────────────── */
|
||||||
actions: {
|
actions: {
|
||||||
async initUserFromCookie({ dispatch }): Promise<boolean | null> {
|
async initUserFromCookie({ dispatch }): Promise<boolean | null> {
|
||||||
const jwtToken = getCookie("authorization");
|
const jwtToken = getAuthorizationCookie();
|
||||||
if (!jwtToken) return null;
|
if (!jwtToken) return null;
|
||||||
|
|
||||||
const token = parseJwt(jwtToken);
|
const token = parseJwt(jwtToken);
|
||||||
|
|||||||
53
src/pages/MissingPlexAuthPage.vue
Normal file
53
src/pages/MissingPlexAuthPage.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<div class="not-authenticated">
|
||||||
|
<h1><IconStop /> Must be authenticated with Plex</h1>
|
||||||
|
<p>Go to Settings to link your Plex account</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import IconStop from "@/icons/IconStop.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.not-authenticated {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin-right: 1rem;
|
||||||
|
height: 3rem;
|
||||||
|
width: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--text-color-60);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding: 1rem;
|
||||||
|
padding-right: 0;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.65rem;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin-right: 1rem;
|
||||||
|
height: 2rem;
|
||||||
|
width: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,8 @@ import type { RouteRecordRaw, RouteLocationNormalized } from "vue-router";
|
|||||||
|
|
||||||
/* eslint-disable-next-line import-x/no-cycle */
|
/* eslint-disable-next-line import-x/no-cycle */
|
||||||
import store from "./store";
|
import store from "./store";
|
||||||
|
import { usePlexAuth } from "./composables/usePlexAuth";
|
||||||
|
const { getPlexAuthCookie } = usePlexAuth();
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -83,6 +85,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
component: () => import("./pages/AdminPage.vue")
|
component: () => import("./pages/AdminPage.vue")
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "missing-plex-auth",
|
||||||
|
path: "/missing/plex",
|
||||||
|
component: () => import("./pages/MissingPlexAuthPage.vue")
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/:pathMatch(.*)*",
|
path: "/:pathMatch(.*)*",
|
||||||
name: "NotFound",
|
name: "NotFound",
|
||||||
@@ -111,16 +118,8 @@ const hasPlexAccount = () => {
|
|||||||
if (store.getters["user/plexUserId"] !== null) return true;
|
if (store.getters["user/plexUserId"] !== null) return true;
|
||||||
|
|
||||||
// Fallback to localStorage
|
// Fallback to localStorage
|
||||||
const userData = localStorage.getItem("plex_user_data");
|
const authToken = getPlexAuthCookie();
|
||||||
if (userData) {
|
return !!authToken;
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(userData);
|
|
||||||
return parsed.id !== null && parsed.id !== undefined;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
const hamburgerIsOpen = () => store.getters["hamburger/isOpen"];
|
const hamburgerIsOpen = () => store.getters["hamburger/isOpen"];
|
||||||
|
|
||||||
@@ -136,15 +135,14 @@ router.beforeEach(
|
|||||||
// send user to signin page.
|
// send user to signin page.
|
||||||
if (to.matched.some(record => record.meta.requiresAuth)) {
|
if (to.matched.some(record => record.meta.requiresAuth)) {
|
||||||
if (!loggedIn()) {
|
if (!loggedIn()) {
|
||||||
next({ path: "/signin" });
|
next({ path: "/login" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.matched.some(record => record.meta.requiresPlexAccount)) {
|
if (to.matched.some(record => record.meta.requiresPlexAccount)) {
|
||||||
if (!hasPlexAccount()) {
|
if (!hasPlexAccount()) {
|
||||||
next({
|
next({
|
||||||
path: "/settings",
|
path: "/missing/plex"
|
||||||
query: { missingPlexAccount: true }
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export function getLibraryIcon(type: string): string {
|
export function getLibraryIcon(type: string): string {
|
||||||
const icons: Record<string, string> = {
|
const icons: Record<string, string> = {
|
||||||
movies: "🎬",
|
movies: "🎬",
|
||||||
shows: "📺",
|
"tv shows": "📺",
|
||||||
music: "🎵"
|
music: "🎵"
|
||||||
};
|
};
|
||||||
return icons[type] || "📁";
|
return icons[type] || "📁";
|
||||||
@@ -10,7 +10,7 @@ export function getLibraryIcon(type: string): string {
|
|||||||
export function getLibraryIconComponent(type: string): string {
|
export function getLibraryIconComponent(type: string): string {
|
||||||
const components: Record<string, string> = {
|
const components: Record<string, string> = {
|
||||||
movies: "IconMovie",
|
movies: "IconMovie",
|
||||||
shows: "IconShow",
|
"tv shows": "IconShow",
|
||||||
music: "IconMusic"
|
music: "IconMusic"
|
||||||
};
|
};
|
||||||
return components[type] || "IconMovie";
|
return components[type] || "IconMovie";
|
||||||
@@ -19,7 +19,7 @@ export function getLibraryIconComponent(type: string): string {
|
|||||||
export function getLibraryTitle(type: string): string {
|
export function getLibraryTitle(type: string): string {
|
||||||
const titles: Record<string, string> = {
|
const titles: Record<string, string> = {
|
||||||
movies: "Movies",
|
movies: "Movies",
|
||||||
shows: "TV Shows",
|
"tv shows": "TV Shows",
|
||||||
music: "Music"
|
music: "Music"
|
||||||
};
|
};
|
||||||
return titles[type] || type;
|
return titles[type] || type;
|
||||||
@@ -62,8 +62,8 @@ export function processLibraryItem(
|
|||||||
// Get poster/thumbnail URL
|
// Get poster/thumbnail URL
|
||||||
let posterUrl = null;
|
let posterUrl = null;
|
||||||
|
|
||||||
// For TV shows, prefer grandparentThumb (show poster) over thumb (episode thumbnail)
|
// For TV tv shows, prefer grandparentThumb (show poster) over thumb (episode thumbnail)
|
||||||
if (libraryType === "shows") {
|
if (libraryType === "tv shows") {
|
||||||
if (item.grandparentThumb) {
|
if (item.grandparentThumb) {
|
||||||
posterUrl = `${serverUrl}${item.grandparentThumb}?X-Plex-Token=${authToken}`;
|
posterUrl = `${serverUrl}${item.grandparentThumb}?X-Plex-Token=${authToken}`;
|
||||||
} else if (item.thumb) {
|
} else if (item.thumb) {
|
||||||
@@ -92,14 +92,14 @@ export function processLibraryItem(
|
|||||||
plexUrl = `https://app.plex.tv/desktop/#!/server/${machineIdentifier}/details?key=${encodedKey}`;
|
plexUrl = `https://app.plex.tv/desktop/#!/server/${machineIdentifier}/details?key=${encodedKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For shows, use grandparent data (show info) instead of episode info
|
// For tv shows, use grandparent data (show info) instead of episode info
|
||||||
const title =
|
const title =
|
||||||
libraryType === "shows" && item.grandparentTitle
|
libraryType === "tv shows" && item.grandparentTitle
|
||||||
? item.grandparentTitle
|
? item.grandparentTitle
|
||||||
: item.title;
|
: item.title;
|
||||||
|
|
||||||
const year =
|
const year =
|
||||||
libraryType === "shows" && item.grandparentYear
|
libraryType === "tv shows" && item.grandparentYear
|
||||||
? item.grandparentYear
|
? item.grandparentYear
|
||||||
: item.year || item.parentYear || new Date().getFullYear();
|
: item.year || item.parentYear || new Date().getFullYear();
|
||||||
|
|
||||||
@@ -109,11 +109,12 @@ export function processLibraryItem(
|
|||||||
poster: posterUrl,
|
poster: posterUrl,
|
||||||
fallbackIcon: getLibraryIcon(libraryType),
|
fallbackIcon: getLibraryIcon(libraryType),
|
||||||
rating: item.rating ? Math.round(item.rating * 10) / 10 : null,
|
rating: item.rating ? Math.round(item.rating * 10) / 10 : null,
|
||||||
|
type: libraryType,
|
||||||
ratingKey,
|
ratingKey,
|
||||||
plexUrl
|
plexUrl
|
||||||
};
|
};
|
||||||
|
|
||||||
if (libraryType === "shows") {
|
if (libraryType === "tv shows") {
|
||||||
return {
|
return {
|
||||||
...baseItem,
|
...baseItem,
|
||||||
episodes: item.leafCount || 0
|
episodes: item.leafCount || 0
|
||||||
@@ -157,7 +158,7 @@ export function calculateDuration(metadata: any[], libraryType: string) {
|
|||||||
totalDuration += item.duration;
|
totalDuration += item.duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryType === "shows" && item.leafCount) {
|
if (libraryType === "tv shows" && item.leafCount) {
|
||||||
totalEpisodes += item.leafCount;
|
totalEpisodes += item.leafCount;
|
||||||
} else if (libraryType === "music" && item.leafCount) {
|
} else if (libraryType === "music" && item.leafCount) {
|
||||||
totalTracks += item.leafCount;
|
totalTracks += item.leafCount;
|
||||||
|
|||||||
Reference in New Issue
Block a user