Data structures cleanup.

This commit is contained in:
2026-02-09 04:03:24 +00:00
parent f65c4ff3de
commit 3e2fb15f0f
17 changed files with 511 additions and 611 deletions
+41 -42
View File
@@ -370,7 +370,7 @@ function showDetail(item: MediaItem) {
if (item.type === 'episode') { if (item.type === 'episode') {
// For episodes, play directly if possible, otherwise show the series // For episodes, play directly if possible, otherwise show the series
const epData = item.data as EpisodeWithSeries; const epData = item.data as EpisodeWithSeries;
const playableFile = epData.episode.releases?.[0]?.playable_file; const playableFile = Object.values(epData.episode.torrents || {})[0]?.playable_file;
if (playableFile) { if (playableFile) {
handlePlay(playableFile); handlePlay(playableFile);
} else { } else {
@@ -389,10 +389,9 @@ function closeDetail() {
// Convert raw data to MediaItem format // Convert raw data to MediaItem format
function movieToMediaItem(movie: Movie): MediaItem { function movieToMediaItem(movie: Movie): MediaItem {
// Get resolution from first version if available // Get resolution from first torrent if available
const resolution = movie.versions && movie.versions.length > 0 const torrents = Object.values(movie.torrents || {});
? movie.versions[0].resolution const resolution = torrents.length > 0 ? torrents[0].resolution : null;
: null;
// Use first showreel image as fallback if no cover // Use first showreel image as fallback if no cover
const coverPath = movie.cover_path || const coverPath = movie.cover_path ||
@@ -400,7 +399,7 @@ function movieToMediaItem(movie: Movie): MediaItem {
return { return {
id: movie.id, id: movie.id,
title: movie.title, title: movie.title || 'Unknown',
year: movie.year, year: movie.year,
cover_path: coverPath, cover_path: coverPath,
showreel_images: movie.showreel_images, showreel_images: movie.showreel_images,
@@ -426,7 +425,7 @@ function seriesToMediaItem(series: Series): MediaItem {
return { return {
id: series.id, id: series.id,
title: series.title, title: series.title || 'Unknown',
year: null, year: null,
cover_path: coverPath, cover_path: coverPath,
showreel_images: reelImages.length > 0 ? reelImages : null, showreel_images: reelImages.length > 0 ? reelImages : null,
@@ -465,16 +464,16 @@ function sortByRating(items: MediaItem[]): MediaItem[] {
if (a.type === 'episode') { if (a.type === 'episode') {
const epData = a.data as EpisodeWithSeries; const epData = a.data as EpisodeWithSeries;
ratingA = epData.episode.rating ?? epData.series.rating ?? 0; ratingA = epData.episode.rating ?? epData.series.info?.rating ?? 0;
} else { } else {
ratingA = (a.data as Movie | Series).rating ?? 0; ratingA = (a.data as Movie | Series).info?.rating ?? 0;
} }
if (b.type === 'episode') { if (b.type === 'episode') {
const epData = b.data as EpisodeWithSeries; const epData = b.data as EpisodeWithSeries;
ratingB = epData.episode.rating ?? epData.series.rating ?? 0; ratingB = epData.episode.rating ?? epData.series.info?.rating ?? 0;
} else { } else {
ratingB = (b.data as Movie | Series).rating ?? 0; ratingB = (b.data as Movie | Series).info?.rating ?? 0;
} }
return ratingB - ratingA; return ratingB - ratingA;
@@ -492,7 +491,7 @@ const moviesByGenre = computed(() => {
// Apply search filter // Apply search filter
const searchFilter = (m: MediaItem) => { const searchFilter = (m: MediaItem) => {
if (!searchQuery.value) return true; if (!searchQuery.value) return true;
return m.title.toLowerCase().includes(searchQuery.value.toLowerCase()); return (m.title || '').toLowerCase().includes(searchQuery.value.toLowerCase());
}; };
// Assign movies to categories by matching priority (lowest priority number first) // Assign movies to categories by matching priority (lowest priority number first)
@@ -503,7 +502,7 @@ const moviesByGenre = computed(() => {
if (!searchFilter(movie)) continue; if (!searchFilter(movie)) continue;
const movieData = movie.data as Movie; const movieData = movie.data as Movie;
const genres = movieData.genres || []; const genres = movieData.info?.genres || [];
// Check if movie matches this category (has keyword and no excluded genres) // Check if movie matches this category (has keyword and no excluded genres)
const hasKeyword = category.keywords.some(keyword => const hasKeyword = category.keywords.some(keyword =>
@@ -563,7 +562,7 @@ const seriesByGenre = computed(() => {
// Apply search filter // Apply search filter
const searchFilter = (s: MediaItem) => { const searchFilter = (s: MediaItem) => {
if (!searchQuery.value) return true; if (!searchQuery.value) return true;
return s.title.toLowerCase().includes(searchQuery.value.toLowerCase()); return (s.title || '').toLowerCase().includes(searchQuery.value.toLowerCase());
}; };
// Assign series to categories by matching priority (lowest priority number first) // Assign series to categories by matching priority (lowest priority number first)
@@ -574,7 +573,7 @@ const seriesByGenre = computed(() => {
if (!searchFilter(series)) continue; if (!searchFilter(series)) continue;
const seriesData = series.data as Series; const seriesData = series.data as Series;
const genres = seriesData.genres || []; const genres = seriesData.info?.genres || [];
// Check if series matches this category (has keyword and no excluded genres) // Check if series matches this category (has keyword and no excluded genres)
const hasKeyword = category.keywords.some(keyword => const hasKeyword = category.keywords.some(keyword =>
@@ -784,7 +783,7 @@ function performSearch(query: string) {
if (movie.year === searchYear) { if (movie.year === searchYear) {
allScored.push({ allScored.push({
item: movieToMediaItem(movie), item: movieToMediaItem(movie),
score: 100 + (movie.rating ?? 0) / 10, // High base score, ranked by rating score: 100 + (movie.info?.rating ?? 0) / 10, // High base score, ranked by rating
matchType: 'movies' matchType: 'movies'
}); });
processedIds.add(movie.id); processedIds.add(movie.id);
@@ -793,11 +792,11 @@ function performSearch(query: string) {
} }
// Direct title match -> Movies category // Direct title match -> Movies category
const titleScore = getBestScore(query, movie.title, movie.original_title); const titleScore = getBestScore(query, movie.title, movie.info?.original_title);
if (titleScore > 0) { if (titleScore > 0) {
allScored.push({ allScored.push({
item: movieToMediaItem(movie), item: movieToMediaItem(movie),
score: titleScore + (movie.rating ?? 0) / 10, score: titleScore + (movie.info?.rating ?? 0) / 10,
matchType: 'movies' matchType: 'movies'
}); });
processedIds.add(movie.id); processedIds.add(movie.id);
@@ -805,13 +804,13 @@ function performSearch(query: string) {
} }
// Cast/director match -> People category // Cast/director match -> People category
const peopleMatch = matchesPeople(query, movie.cast, movie.director); const peopleMatch = matchesPeople(query, movie.info?.cast, movie.info?.director);
if (peopleMatch.matches.length > 0) { if (peopleMatch.matches.length > 0) {
const item = movieToMediaItem(movie); const item = movieToMediaItem(movie);
item.searchMatchInfo = { matchedPeople: formatMatchedPeople(peopleMatch.matches) }; item.searchMatchInfo = { matchedPeople: formatMatchedPeople(peopleMatch.matches) };
allScored.push({ allScored.push({
item, item,
score: peopleMatch.score + (movie.rating ?? 0) / 10, score: peopleMatch.score + (movie.info?.rating ?? 0) / 10,
matchType: 'people' matchType: 'people'
}); });
processedIds.add(movie.id); processedIds.add(movie.id);
@@ -820,16 +819,16 @@ function performSearch(query: string) {
// Other metadata matches -> Other category // Other metadata matches -> Other category
const otherScore = getBestScore(query, const otherScore = getBestScore(query,
movie.genres?.join(' '), movie.info?.genres?.join(' '),
movie.keywords?.join(' '), movie.info?.keywords?.join(' '),
movie.overview, movie.info?.overview,
movie.tagline, movie.info?.tagline,
movie.similar?.map(s => s.title).join(' ') movie.info?.similar?.map(s => s.title).join(' ')
); );
if (otherScore > 0) { if (otherScore > 0) {
allScored.push({ allScored.push({
item: movieToMediaItem(movie), item: movieToMediaItem(movie),
score: otherScore + (movie.rating ?? 0) / 10, score: otherScore + (movie.info?.rating ?? 0) / 10,
matchType: 'other' matchType: 'other'
}); });
processedIds.add(movie.id); processedIds.add(movie.id);
@@ -841,11 +840,11 @@ function performSearch(query: string) {
// Year search - match series that started that year // Year search - match series that started that year
if (isYearSearch) { if (isYearSearch) {
// Extract year from release_date (format: "YYYY-MM-DD" or just "YYYY") // Extract year from release_date (format: "YYYY-MM-DD" or just "YYYY")
const seriesYear = series.release_date ? parseInt(series.release_date.substring(0, 4), 10) : null; const seriesYear = series.info?.release_date ? parseInt(series.info.release_date.substring(0, 4), 10) : null;
if (seriesYear === searchYear) { if (seriesYear === searchYear) {
allScored.push({ allScored.push({
item: seriesToMediaItem(series), item: seriesToMediaItem(series),
score: 100 + (series.rating ?? 0) / 10, score: 100 + (series.info?.rating ?? 0) / 10,
matchType: 'series' matchType: 'series'
}); });
processedIds.add(series.id); processedIds.add(series.id);
@@ -854,11 +853,11 @@ function performSearch(query: string) {
} }
// Direct title match -> Series category // Direct title match -> Series category
const titleScore = getBestScore(query, series.title, series.original_title); const titleScore = getBestScore(query, series.title, series.info?.original_title);
if (titleScore > 0) { if (titleScore > 0) {
allScored.push({ allScored.push({
item: seriesToMediaItem(series), item: seriesToMediaItem(series),
score: titleScore + (series.rating ?? 0) / 10, score: titleScore + (series.info?.rating ?? 0) / 10,
matchType: 'series' matchType: 'series'
}); });
processedIds.add(series.id); processedIds.add(series.id);
@@ -869,8 +868,8 @@ function performSearch(query: string) {
const matchedEpisodes: MatchedEpisode[] = []; const matchedEpisodes: MatchedEpisode[] = [];
let episodeScore = 0; let episodeScore = 0;
// Check if series has only one season and has ended (hide "SN" in that case) // Check if series has only one season and has ended (hide "SN" in that case)
const isEndedSingleSeason = (series.number_of_seasons === 1 || (series.seasons?.length === 1)) && const isEndedSingleSeason = (series.info?.number_of_seasons === 1 || (series.seasons?.length === 1)) &&
['Ended', 'Canceled', 'Cancelled'].includes(series.status || ''); ['Ended', 'Canceled', 'Cancelled'].includes(series.info?.status || '');
for (const season of series.seasons || []) { for (const season of series.seasons || []) {
for (const episode of season.episodes || []) { for (const episode of season.episodes || []) {
@@ -898,7 +897,7 @@ function performSearch(query: string) {
item.searchMatchInfo = { matchedEpisodes }; item.searchMatchInfo = { matchedEpisodes };
allScored.push({ allScored.push({
item, item,
score: episodeScore + (series.rating ?? 0) / 10, score: episodeScore + (series.info?.rating ?? 0) / 10,
matchType: 'series' matchType: 'series'
}); });
processedIds.add(series.id); processedIds.add(series.id);
@@ -906,13 +905,13 @@ function performSearch(query: string) {
} }
// Cast/creators match -> People category // Cast/creators match -> People category
const peopleMatch = matchesPeople(query, series.cast, null, series.creators); const peopleMatch = matchesPeople(query, series.info?.cast, null, series.info?.creators);
if (peopleMatch.matches.length > 0 && !processedIds.has(series.id)) { if (peopleMatch.matches.length > 0 && !processedIds.has(series.id)) {
const item = seriesToMediaItem(series); const item = seriesToMediaItem(series);
item.searchMatchInfo = { matchedPeople: formatMatchedPeople(peopleMatch.matches) }; item.searchMatchInfo = { matchedPeople: formatMatchedPeople(peopleMatch.matches) };
allScored.push({ allScored.push({
item, item,
score: peopleMatch.score + (series.rating ?? 0) / 10, score: peopleMatch.score + (series.info?.rating ?? 0) / 10,
matchType: 'people' matchType: 'people'
}); });
processedIds.add(series.id); processedIds.add(series.id);
@@ -922,17 +921,17 @@ function performSearch(query: string) {
// Other metadata matches -> Other category // Other metadata matches -> Other category
if (!processedIds.has(series.id)) { if (!processedIds.has(series.id)) {
const otherScore = getBestScore(query, const otherScore = getBestScore(query,
series.genres?.join(' '), series.info?.genres?.join(' '),
series.keywords?.join(' '), series.info?.keywords?.join(' '),
series.overview, series.info?.overview,
series.tagline, series.info?.tagline,
series.similar?.map(s => s.title).join(' '), series.info?.similar?.map(s => s.title).join(' '),
series.networks?.join(' ') series.info?.networks?.join(' ')
); );
if (otherScore > 0) { if (otherScore > 0) {
allScored.push({ allScored.push({
item: seriesToMediaItem(series), item: seriesToMediaItem(series),
score: otherScore + (series.rating ?? 0) / 10, score: otherScore + (series.info?.rating ?? 0) / 10,
matchType: 'other' matchType: 'other'
}); });
processedIds.add(series.id); processedIds.add(series.id);
+10 -10
View File
@@ -16,7 +16,7 @@
<img <img
v-if="getImageUrl(item)" v-if="getImageUrl(item)"
:src="getImageUrl(item)!" :src="getImageUrl(item)!"
:alt="item.title" :alt="item.title || 'Unknown'"
class="hex-clip" class="hex-clip"
/> />
</div> </div>
@@ -28,7 +28,7 @@
<img <img
v-if="index !== 0 && getImageUrl(item)" v-if="index !== 0 && getImageUrl(item)"
:src="getImageUrl(item)!" :src="getImageUrl(item)!"
:alt="item.title" :alt="item.title || 'Unknown'"
class="collage-media" class="collage-media"
/> />
<video <video
@@ -444,9 +444,9 @@ function getVideoUrl(item: MediaItem): string | undefined {
function getRating(item: MediaItem): number | null { function getRating(item: MediaItem): number | null {
if (item.type === 'movies') { if (item.type === 'movies') {
return (item.data as Movie).rating ?? null; return (item.data as Movie).info?.rating ?? null;
} }
return (item.data as Series).rating ?? null; return (item.data as Series).info?.rating ?? null;
} }
function getRatingClass(item: MediaItem): string { function getRatingClass(item: MediaItem): string {
@@ -460,15 +460,15 @@ function getRatingClass(item: MediaItem): string {
function getResolution(item: MediaItem): string | null { function getResolution(item: MediaItem): string | null {
if (item.type === 'movies') { if (item.type === 'movies') {
const movie = item.data as Movie; const movie = item.data as Movie;
return movie.versions?.[0]?.resolution ?? null; return Object.values(movie.torrents || {})[0]?.resolution ?? null;
} }
return null; return null;
} }
function getOverview(item: MediaItem): string | null { function getOverview(item: MediaItem): string | null {
const overview = item.type === 'movies' const overview = item.type === 'movies'
? (item.data as Movie).overview ? (item.data as Movie).info?.overview
: (item.data as Series).overview; : (item.data as Series).info?.overview;
if (!overview) return null; if (!overview) return null;
return overview.length > 150 ? overview.slice(0, 150) + '...' : overview; return overview.length > 150 ? overview.slice(0, 150) + '...' : overview;
} }
@@ -476,13 +476,13 @@ function getOverview(item: MediaItem): string | null {
function getPlayableFile(item: MediaItem): string | null { function getPlayableFile(item: MediaItem): string | null {
if (item.type === 'movies') { if (item.type === 'movies') {
const movie = item.data as Movie; const movie = item.data as Movie;
return movie.versions?.[0]?.playable_file ?? null; return Object.values(movie.torrents || {})[0]?.playable_file ?? null;
} }
const series = item.data as Series; const series = item.data as Series;
for (const season of series.seasons || []) { for (const season of series.seasons || []) {
for (const episode of season.episodes || []) { for (const episode of season.episodes || []) {
for (const release of episode.releases || []) { for (const torrent of Object.values(episode.torrents || {})) {
if (release.playable_file) return release.playable_file; if (torrent.playable_file) return torrent.playable_file;
} }
} }
} }
+15 -15
View File
@@ -1,6 +1,6 @@
<template> <template>
<section class="hero"> <section class="hero">
<div <div
v-if="coverUrl" v-if="coverUrl"
class="hero-background" class="hero-background"
:style="{ backgroundImage: `url('${coverUrl}')` }" :style="{ backgroundImage: `url('${coverUrl}')` }"
@@ -53,7 +53,8 @@ const coverUrl = computed(() => {
const resolution = computed(() => { const resolution = computed(() => {
if (props.item.type === 'movies') { if (props.item.type === 'movies') {
const movie = props.item.data as Movie; const movie = props.item.data as Movie;
return movie.versions && movie.versions.length > 0 ? movie.versions[0].resolution : null; const torrents = Object.values(movie.torrents || {});
return torrents.length > 0 ? torrents[0].resolution : null;
} }
return null; return null;
}); });
@@ -61,16 +62,17 @@ const resolution = computed(() => {
const quality = computed(() => { const quality = computed(() => {
if (props.item.type === 'movies') { if (props.item.type === 'movies') {
const movie = props.item.data as Movie; const movie = props.item.data as Movie;
return movie.versions && movie.versions.length > 0 ? movie.versions[0].quality : null; const torrents = Object.values(movie.torrents || {});
return torrents.length > 0 ? torrents[0].quality : null;
} }
return null; return null;
}); });
const rating = computed(() => { const rating = computed(() => {
if (props.item.type === 'movies') { if (props.item.type === 'movies') {
return (props.item.data as Movie).rating; return (props.item.data as Movie).info?.rating;
} }
return (props.item.data as Series).rating; return (props.item.data as Series).info?.rating;
}); });
const ratingClass = computed(() => { const ratingClass = computed(() => {
@@ -82,29 +84,27 @@ const ratingClass = computed(() => {
const overview = computed(() => { const overview = computed(() => {
if (props.item.type === 'movies') { if (props.item.type === 'movies') {
const o = (props.item.data as Movie).overview; const o = (props.item.data as Movie).info?.overview;
return o ? (o.length > 200 ? o.slice(0, 200) + '...' : o) : null; return o ? (o.length > 200 ? o.slice(0, 200) + '...' : o) : null;
} }
const o = (props.item.data as Series).overview; const o = (props.item.data as Series).info?.overview;
return o ? (o.length > 200 ? o.slice(0, 200) + '...' : o) : null; return o ? (o.length > 200 ? o.slice(0, 200) + '...' : o) : null;
}); });
const playableFile = computed(() => { const playableFile = computed(() => {
if (props.item.type === 'movies') { if (props.item.type === 'movies') {
const movie = props.item.data as Movie; const movie = props.item.data as Movie;
// Get the first version's playable file const torrents = Object.values(movie.torrents || {});
if (movie.versions && movie.versions.length > 0) { return torrents.length > 0 ? torrents[0].playable_file : null;
return movie.versions[0].playable_file;
}
return null;
} }
// For series, get first available file from episodes // For series, get first available file from episodes
const series = props.item.data as Series; const series = props.item.data as Series;
for (const season of series.seasons || []) { for (const season of series.seasons || []) {
for (const episode of season.episodes || []) { for (const episode of season.episodes || []) {
for (const release of episode.releases || []) { const torrents = Object.values(episode.torrents || {});
if (release.playable_file) { for (const torrent of torrents) {
return release.playable_file; if (torrent.playable_file) {
return torrent.playable_file;
} }
} }
} }
+7 -7
View File
@@ -14,7 +14,7 @@
<img <img
v-if="coverUrl && !imageError" v-if="coverUrl && !imageError"
:src="coverUrl" :src="coverUrl"
:alt="item.title" :alt="item.title || 'Unknown'"
loading="lazy" loading="lazy"
@error="imageError = true" @error="imageError = true"
/> />
@@ -90,13 +90,13 @@ const coverUrl = computed(() => {
const rating = computed(() => { const rating = computed(() => {
if (props.item.type === 'movies') { if (props.item.type === 'movies') {
return (props.item.data as Movie).rating; return (props.item.data as Movie).info?.rating;
} }
if (props.item.type === 'episode') { if (props.item.type === 'episode') {
const epData = props.item.data as EpisodeWithSeries; const epData = props.item.data as EpisodeWithSeries;
return epData.episode.rating ?? epData.series.rating; return epData.episode.rating ?? epData.series.info?.rating;
} }
return (props.item.data as Series).rating; return (props.item.data as Series).info?.rating;
}); });
const ratingClass = computed(() => { const ratingClass = computed(() => {
@@ -121,7 +121,7 @@ const subtitle = computed(() => {
} }
// For series, show creators // For series, show creators
if (props.item.type === 'series') { if (props.item.type === 'series') {
const creators = (props.item.data as Series).creators; const creators = (props.item.data as Series).info?.creators;
return creators && creators.length > 0 ? creators.join(', ') : null; return creators && creators.length > 0 ? creators.join(', ') : null;
} }
return null; return null;
@@ -130,7 +130,7 @@ const subtitle = computed(() => {
// Director for movies // Director for movies
const director = computed(() => { const director = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).director; return (props.item.data as Movie).info?.director;
}); });
// Check if we have director and/or cast to display // Check if we have director and/or cast to display
@@ -142,7 +142,7 @@ const directorAndCast = computed(() => {
// Cast names, excluding director if they appear in cast // Cast names, excluding director if they appear in cast
const filteredCastNames = computed(() => { const filteredCastNames = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== 'movies') return null;
const cast = (props.item.data as Movie).cast; const cast = (props.item.data as Movie).info?.cast;
if (!cast || cast.length === 0) return null; if (!cast || cast.length === 0) return null;
const directorName = director.value?.toLowerCase(); const directorName = director.value?.toLowerCase();
+17 -17
View File
@@ -99,7 +99,7 @@
<button <button
class="btn btn-small btn-secondary" class="btn btn-small btn-secondary"
v-bind="navAttrs(2, index * 2 + 1)" v-bind="navAttrs(2, index * 2 + 1)"
@click="handleOpenFolder(version.path)" @click="handleOpenFolder(version.playable_file || '')"
>📁</button> >📁</button>
</div> </div>
</div> </div>
@@ -140,7 +140,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'; import { computed, ref, watch, onMounted } from 'vue';
import type { MediaItem, Movie, MovieVersion, Series } from '../types'; import type { MediaItem, Movie, Series, Torrent } from '../types';
import { getCoverUrl } from '../api'; import { getCoverUrl } from '../api';
import SeriesFullView from './SeriesFullView.vue'; import SeriesFullView from './SeriesFullView.vue';
import { navAttrs } from '../composables/useKeyboardNavigation'; import { navAttrs } from '../composables/useKeyboardNavigation';
@@ -270,10 +270,10 @@ function getShowreelUrl(path: string): string {
return getCoverUrl(path); return getCoverUrl(path);
} }
// Movie versions // Movie versions
const movieVersions = computed((): MovieVersion[] => { const movieVersions = computed((): Torrent[] => {
if (props.item.type !== 'movies') return []; if (props.item.type !== 'movies') return [];
const movie = props.item.data as Movie; const movie = props.item.data as Movie;
return movie.versions || []; return Object.values(movie.torrents || {});
}); });
const headerStyle = computed(() => { const headerStyle = computed(() => {
@@ -299,7 +299,7 @@ const backdropStyle = computed(() => {
}); });
// Check if a specific version is a disc format (Blu-ray disc has index.bdmv) // Check if a specific version is a disc format (Blu-ray disc has index.bdmv)
function isVersionDisc(version: MovieVersion): boolean { function isVersionDisc(version: Torrent): boolean {
if (!version.playable_file) return false; if (!version.playable_file) return false;
const filename = version.playable_file.toLowerCase(); const filename = version.playable_file.toLowerCase();
return filename.endsWith('index.bdmv') || filename.endsWith('.iso'); return filename.endsWith('index.bdmv') || filename.endsWith('.iso');
@@ -307,42 +307,42 @@ function isVersionDisc(version: MovieVersion): boolean {
const movieGenres = computed(() => { const movieGenres = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).genres; return (props.item.data as Movie).info?.genres;
}); });
const movieTagline = computed(() => { const movieTagline = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).tagline; return (props.item.data as Movie).info?.tagline;
}); });
const movieDirector = computed(() => { const movieDirector = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).director; return (props.item.data as Movie).info?.director;
}); });
const movieCast = computed(() => { const movieCast = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).cast; return (props.item.data as Movie).info?.cast;
}); });
const movieRuntime = computed(() => { const movieRuntime = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).runtime; return (props.item.data as Movie).info?.runtime;
}); });
const movieReleaseDate = computed(() => { const movieReleaseDate = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).release_date; return (props.item.data as Movie).info?.release_date;
}); });
const movieStatus = computed(() => { const movieStatus = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).status; return (props.item.data as Movie).info?.status;
}); });
const movieKeywords = computed(() => { const movieKeywords = computed(() => {
if (props.item.type !== 'movies') return null; if (props.item.type !== 'movies') return null;
return (props.item.data as Movie).keywords; return (props.item.data as Movie).info?.keywords;
}); });
function formatRuntime(minutes: number): string { function formatRuntime(minutes: number): string {
@@ -354,16 +354,16 @@ function formatRuntime(minutes: number): string {
const rating = computed(() => { const rating = computed(() => {
if (props.item.type === 'movies') { if (props.item.type === 'movies') {
return (props.item.data as Movie).rating; return (props.item.data as Movie).info?.rating;
} }
return (props.item.data as Series).rating; return (props.item.data as Series).info?.rating;
}); });
const overview = computed(() => { const overview = computed(() => {
if (props.item.type === 'movies') { if (props.item.type === 'movies') {
return (props.item.data as Movie).overview; return (props.item.data as Movie).info?.overview;
} }
return (props.item.data as Series).overview; return (props.item.data as Series).info?.overview;
}); });
const ratingClass = computed(() => { const ratingClass = computed(() => {
+24 -24
View File
@@ -5,7 +5,7 @@
<section class="series-hero"> <section class="series-hero">
<div class="hero-bg"> <div class="hero-bg">
<!-- Use backdrop if available, otherwise create collage from season posters --> <!-- Use backdrop if available, otherwise create collage from season posters -->
<img v-if="backdropUrl" :src="backdropUrl" class="hero-img" :alt="series.title" /> <img v-if="backdropUrl" :src="backdropUrl" class="hero-img" :alt="series.title || 'Unknown'" />
<div v-else class="hero-collage"> <div v-else class="hero-collage">
<div <div
v-for="(season, i) in seasonsWithPosters.slice(0, 5)" v-for="(season, i) in seasonsWithPosters.slice(0, 5)"
@@ -19,12 +19,12 @@
<div class="hero-content"> <div class="hero-content">
<h1 class="series-title">{{ series.title }}</h1> <h1 class="series-title">{{ series.title }}</h1>
<div class="series-meta"> <div class="series-meta">
<span v-if="series.rating" class="meta-rating" :class="ratingClass"> {{ series.rating.toFixed(1) }}</span> <span v-if="series.info?.rating" class="meta-rating" :class="ratingClass"> {{ series.info.rating.toFixed(1) }}</span>
<span v-if="series.number_of_seasons" class="meta-item">{{ series.number_of_seasons }} Seasons</span> <span v-if="series.info?.number_of_seasons" class="meta-item">{{ series.info.number_of_seasons }} Seasons</span>
<span v-if="series.status" class="meta-badge">{{ series.status }}</span> <span v-if="series.info?.status" class="meta-badge">{{ series.info.status }}</span>
<span v-if="series.genres?.length" class="meta-genres">{{ series.genres.slice(0, 3).join(' ') }}</span> <span v-if="series.info?.genres?.length" class="meta-genres">{{ series.info.genres.slice(0, 3).join(' ') }}</span>
</div> </div>
<p v-if="series.overview" class="series-overview">{{ series.overview }}</p> <p v-if="series.info?.overview" class="series-overview">{{ series.info.overview }}</p>
</div> </div>
</section> </section>
@@ -125,24 +125,24 @@
<div class="context-menu-header"> <div class="context-menu-header">
{{ contextMenu.episode.name || `Episode ${contextMenu.episode.episode_number}` }} {{ contextMenu.episode.name || `Episode ${contextMenu.episode.episode_number}` }}
</div> </div>
<div v-if="contextMenu.episode.releases && contextMenu.episode.releases.length > 0"> <div v-if="Object.values(contextMenu.episode.torrents || {}).length > 0">
<div <div
v-for="(release, index) in contextMenu.episode.releases" v-for="(torrent, index) in Object.values(contextMenu.episode.torrents || {})"
:key="index" :key="index"
class="context-menu-version" class="context-menu-version"
> >
<div class="version-label">{{ getVersionLabel(release) }}</div> <div class="version-label">{{ getVersionLabel(torrent) }}</div>
<div class="version-actions"> <div class="version-actions">
<button <button
class="ctx-btn ctx-btn-play" class="ctx-btn ctx-btn-play"
tabindex="0" tabindex="0"
@click="handlePlayVersion(release.playable_file)" @click="handlePlayVersion(torrent.playable_file)"
:disabled="!release.playable_file" :disabled="!torrent.playable_file"
> Play</button> > Play</button>
<button <button
class="ctx-btn ctx-btn-folder" class="ctx-btn ctx-btn-folder"
tabindex="0" tabindex="0"
@click="handleOpenFolder(release.path)" @click="handleOpenFolder(torrent.playable_file || '')"
>📁</button> >📁</button>
</div> </div>
</div> </div>
@@ -157,7 +157,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, nextTick, watch } from 'vue'; import { computed, ref, nextTick, watch } from 'vue';
import type { Series, Season, Episode, EpisodeRelease } from '../types'; import type { Series, Season, Episode, Torrent } from '../types';
import { getCoverUrl } from '../api'; import { getCoverUrl } from '../api';
import { navAttrs } from '../composables/useKeyboardNavigation'; import { navAttrs } from '../composables/useKeyboardNavigation';
@@ -309,12 +309,12 @@ function handleOpenFolder(folderPath: string) {
} }
// Get version display label // Get version display label
function getVersionLabel(release: EpisodeRelease): string { function getVersionLabel(torrent: Torrent): string {
const parts: string[] = []; const parts: string[] = [];
if (release.resolution) parts.push(release.resolution); if (torrent.resolution) parts.push(torrent.resolution);
if (release.quality) parts.push(release.quality); if (torrent.quality) parts.push(torrent.quality);
if (release.codec) parts.push(release.codec); if (torrent.codec) parts.push(torrent.codec);
if (release.audio) parts.push(release.audio); if (torrent.audio) parts.push(torrent.audio);
return parts.length > 0 ? parts.join(' • ') : 'Unknown'; return parts.length > 0 ? parts.join(' • ') : 'Unknown';
} }
@@ -390,8 +390,8 @@ function handleEpisodeHover(key: string, isEntering: boolean) {
// Backdrop URL - only use backdrop_path, fall back to collage (handled in template) // Backdrop URL - only use backdrop_path, fall back to collage (handled in template)
const backdropUrl = computed(() => { const backdropUrl = computed(() => {
if (props.series.backdrop_path) { if (props.series.info?.backdrop_path) {
return getCoverUrl(props.series.backdrop_path); return getCoverUrl(props.series.info.backdrop_path);
} }
return null; return null;
}); });
@@ -403,9 +403,9 @@ const seasonsWithPosters = computed(() => {
// Rating class // Rating class
const ratingClass = computed(() => { const ratingClass = computed(() => {
if (!props.series.rating) return ''; if (!props.series.info?.rating) return '';
if (props.series.rating >= 7.5) return 'rating-high'; if (props.series.info.rating >= 7.5) return 'rating-high';
if (props.series.rating >= 6) return 'rating-medium'; if (props.series.info.rating >= 6) return 'rating-medium';
return 'rating-low'; return 'rating-low';
}); });
@@ -450,7 +450,7 @@ function truncate(text: string, maxLength: number): string {
// Handle play // Handle play
function handlePlay(episode: Episode) { function handlePlay(episode: Episode) {
const playableFile = episode.releases?.[0]?.playable_file; const playableFile = Object.values(episode.torrents || {})[0]?.playable_file;
if (playableFile) { if (playableFile) {
emit('play', playableFile); emit('play', playableFile);
} }
+31 -55
View File
@@ -1,19 +1,6 @@
// Type definitions for the media browser // Type definitions for the media browser
export interface MovieVersion { export interface CastMember {
path: string;
playable_file: string | null;
resolution: string | null;
quality: string | null;
codec: string | null;
audio: string | null;
encoder: string | null;
size: number | null;
in_rtorrent: boolean | null;
torrent_path: string | null;
}
export interface TmdbPerson {
name: string; name: string;
character?: string | null; character?: string | null;
profile_path: string | null; profile_path: string | null;
@@ -25,18 +12,11 @@ export interface SimilarMedia {
poster_path: string | null; poster_path: string | null;
} }
export interface Movie { export interface Info {
id: string; tmdb_id: number;
title: string; title: string | null;
original_title: string | null; original_title: string | null;
alternative_titles: string[] | null; alternative_titles: string[] | null;
torrent_title: string | null;
year: number | null;
cover_path: string | null;
showreel_images: string[] | null;
versions: MovieVersion[];
tmdb_id: number | null;
tmdb_title: string | null;
rating: number | null; rating: number | null;
vote_count: number | null; vote_count: number | null;
overview: string | null; overview: string | null;
@@ -49,13 +29,16 @@ export interface Movie {
backdrop_path: string | null; backdrop_path: string | null;
similar: SimilarMedia[] | null; similar: SimilarMedia[] | null;
keywords: string[] | null; keywords: string[] | null;
cast: TmdbPerson[] | null; cast: CastMember[] | null;
director: string | null; director: string | null;
newest: number | null; creators: string[] | null;
number_of_seasons: number | null;
number_of_episodes: number | null;
networks: string[] | null;
} }
export interface EpisodeRelease { export interface Torrent {
path: string; title: string | null;
playable_file: string | null; playable_file: string | null;
resolution: string | null; resolution: string | null;
quality: string | null; quality: string | null;
@@ -63,7 +46,19 @@ export interface EpisodeRelease {
audio: string | null; audio: string | null;
encoder: string | null; encoder: string | null;
size: number | null; size: number | null;
in_rtorrent: boolean | null; added_at: number | null;
}
export interface Movie {
id: string;
title: string | null;
info: Info | null;
year: number | null;
newest: number | null;
cover_path: string | null;
backdrop_path: string | null;
showreel_images: string[] | null;
torrents: { [key: string]: Torrent };
} }
export interface Episode { export interface Episode {
@@ -76,7 +71,7 @@ export interface Episode {
rating: number | null; rating: number | null;
director: string | null; director: string | null;
reel_image: string | null; reel_image: string | null;
releases: EpisodeRelease[]; torrents: { [key: string]: Torrent };
} }
export interface Season { export interface Season {
@@ -91,31 +86,12 @@ export interface Season {
export interface Series { export interface Series {
id: string; id: string;
title: string; title: string | null;
original_title: string | null; info: Info | null;
alternative_titles: string[] | null;
torrent_title: string | null;
cover_path: string | null;
seasons: Season[];
tmdb_id: number | null;
tmdb_title: string | null;
rating: number | null;
vote_count: number | null;
overview: string | null;
genres: string[] | null;
release_date: string | null;
status: string | null;
tagline: string | null;
poster_path: string | null;
backdrop_path: string | null;
similar: SimilarMedia[] | null;
keywords: string[] | null;
cast: TmdbPerson[] | null;
creators: string[] | null;
number_of_seasons: number | null;
number_of_episodes: number | null;
networks: string[] | null;
newest: number | null; newest: number | null;
cover_path: string | null;
backdrop_path: string | null;
seasons: Season[];
} }
export interface MediaStats { export interface MediaStats {
@@ -160,7 +136,7 @@ export interface SearchMatchInfo {
export interface MediaItem { export interface MediaItem {
id: string; id: string;
title: string; title: string | null;
year?: number | null; year?: number | null;
cover_path: string | null; cover_path: string | null;
showreel_images?: string[] | null; showreel_images?: string[] | null;
+7 -16
View File
@@ -6,8 +6,7 @@ Usage:
hivescan /path/* --port 9000 # Custom port hivescan /path/* --port 9000 # Custom port
Or as a library: Or as a library:
from mediahive.hivescan.index_store import IndexStore from mediahive.models.data import Movie, Series, TaskInfo
from mediahive.hivescan.structs import Movie, Series, TaskInfo
from mediahive.hivescan.server import app from mediahive.hivescan.server import app
""" """
@@ -21,21 +20,15 @@ from mediahive.hivescan.scanning import (
from mediahive.hivescan.index_store import IndexStore from mediahive.hivescan.index_store import IndexStore
from mediahive.hivescan.utils import DEFAULT_OUTPUT_FOLDER, find_common_root from mediahive.hivescan.utils import DEFAULT_OUTPUT_FOLDER, find_common_root
from mediahive.hivescan.showreel import generate_showreel_images, generate_episode_reel from mediahive.hivescan.showreel import generate_showreel_images, generate_episode_reel
from mediahive.hivescan.structs import ( from mediahive.models.data import (
CastMember,
Episode, Episode,
EpisodeRelease,
IndexSnapshot, IndexSnapshot,
MediaStats, MediaStats,
Movie, Movie,
MovieVersion,
Season, Season,
Series, Series,
SimilarMedia,
TaskInfo, TaskInfo,
TMDbEpisodeInfo, Torrent,
TMDbInfo,
TMDbSeasonInfo,
) )
from mediahive.hivescan.tmdb_client import ( from mediahive.hivescan.tmdb_client import (
fetch_movie_info, fetch_movie_info,
@@ -59,18 +52,17 @@ __all__ = [
# Struct types # Struct types
"CastMember", "CastMember",
"Episode", "Episode",
"EpisodeRelease",
"IndexSnapshot", "IndexSnapshot",
"MediaStats", "MediaStats",
"Movie", "Movie",
"MovieVersion",
"Season", "Season",
"Series", "Series",
"SimilarMedia", "SimilarMedia",
"TaskInfo", "TaskInfo",
"TMDbEpisodeInfo", "Torrent",
"TMDbInfo", "EpisodeInfo",
"TMDbSeasonInfo", "Info",
"SeasonInfo",
# Showreel generation # Showreel generation
"generate_showreel_images", "generate_showreel_images",
"generate_episode_reel", "generate_episode_reel",
@@ -83,4 +75,3 @@ __all__ = [
"DEFAULT_OUTPUT_FOLDER", "DEFAULT_OUTPUT_FOLDER",
"find_common_root", "find_common_root",
] ]
+3 -2
View File
@@ -18,12 +18,14 @@ import msgspec
from aiopathlib import AsyncPath from aiopathlib import AsyncPath
from fastapi import WebSocket from fastapi import WebSocket
from mediahive.hivescan.structs import ( from mediahive.models.data import (
IndexSnapshot, IndexSnapshot,
MediaStats, MediaStats,
Movie, Movie,
Series, Series,
TaskInfo, TaskInfo,
)
from mediahive.models.protocol import (
WsInit, WsInit,
WsInitData, WsInitData,
WsRemove, WsRemove,
@@ -250,4 +252,3 @@ class IndexStore:
movies=movies_list, movies=movies_list,
series=series_list, series=series_list,
) )
+35 -73
View File
@@ -10,16 +10,12 @@ from mediahive.hivescan.showreel import (
get_expected_episode_reel_path, get_expected_episode_reel_path,
get_expected_showreel_paths, get_expected_showreel_paths,
) )
from mediahive.hivescan.structs import ( from mediahive.models.data import (
Episode, Episode,
EpisodeRelease,
Movie, Movie,
MovieVersion,
Season, Season,
Series, Series,
TMDbEpisodeInfo, Torrent,
TMDbInfo,
TMDbSeasonInfo,
) )
from mediahive.hivescan.tmdb_client import ( from mediahive.hivescan.tmdb_client import (
fetch_movie_info, fetch_movie_info,
@@ -46,18 +42,18 @@ from mediahive.hivescan.utils import (
logger = logging.getLogger("hivescan.indexer") logger = logging.getLogger("hivescan.indexer")
async def _build_version_info( async def _build_torrent_info(
item: ParsedContent, media_root: Optional[str] = None item: ParsedContent, media_root: Optional[str] = None
) -> MovieVersion: ) -> Torrent:
"""Build version/release info for a single torrent.""" """Build torrent info for a single torrent."""
playable_file = await find_playable_file(item.path) playable_file = await find_playable_file(item.path)
if item.content_hash and item.content_hash.size == 0: if item.content_hash and item.content_hash.size == 0:
item.content_hash.size = await get_directory_size(item.content_hash.path) item.content_hash.size = await get_directory_size(item.content_hash.path)
size = item.content_hash.size if item.content_hash else None size = item.content_hash.size if item.content_hash else None
newest = await get_added_timestamp(item.path) added_at = await get_added_timestamp(item.path)
return MovieVersion( return Torrent(
torrent_title=item.title, title=item.title,
playable_file=make_relative_path(playable_file, media_root), playable_file=make_relative_path(playable_file, media_root),
resolution=item.resolution, resolution=item.resolution,
quality=item.quality, quality=item.quality,
@@ -65,7 +61,7 @@ async def _build_version_info(
audio=item.audio, audio=item.audio,
encoder=item.encoder, encoder=item.encoder,
size=size, size=size,
newest=newest, added_at=added_at,
) )
@@ -144,7 +140,7 @@ async def _collect_episode_files(
def _build_episodes_data( def _build_episodes_data(
episodes_in_season: Dict[int, List[Dict]], episodes_in_season: Dict[int, List[Dict]],
tmdb_episodes: Dict[int, TMDbEpisodeInfo], tmdb_episodes: Dict[int, EpisodeInfo],
series_folder: Path, series_folder: Path,
season_num: int, season_num: int,
generate_showreels: bool, generate_showreels: bool,
@@ -175,11 +171,11 @@ def _build_episodes_data(
(best_file, series_folder, season_num, episode_num, series_title) (best_file, series_folder, season_num, episode_num, series_title)
) )
releases = {} torrents = {}
for f in episode_files: for f in episode_files:
relpath = make_relative_path(f["torrent_path"], media_root) relpath = make_relative_path(f["torrent_path"], media_root)
releases[relpath] = EpisodeRelease( torrents[relpath] = Torrent(
torrent_title=f["torrent_title"], title=f["torrent_title"],
playable_file=make_relative_path(f["path"], media_root), playable_file=make_relative_path(f["path"], media_root),
resolution=f.get("resolution"), resolution=f.get("resolution"),
quality=f.get("quality"), quality=f.get("quality"),
@@ -199,7 +195,7 @@ def _build_episodes_data(
rating=tmdb_ep.vote_average if tmdb_ep else None, rating=tmdb_ep.vote_average if tmdb_ep else None,
director=tmdb_ep.director if tmdb_ep else None, director=tmdb_ep.director if tmdb_ep else None,
reel_image=reel_path, reel_image=reel_path,
releases=releases, torrents=torrents,
) )
episodes_data.append(episode_data) episodes_data.append(episode_data)
@@ -231,7 +227,7 @@ async def _build_seasons_data(
# Fetch TMDb season details if we have a TMDb ID # Fetch TMDb season details if we have a TMDb ID
tmdb_season = None tmdb_season = None
tmdb_episodes: Dict[int, TMDbEpisodeInfo] = {} tmdb_episodes: Dict[int, EpisodeInfo] = {}
if tmdb_id: if tmdb_id:
cache_key = (tmdb_id, season_num) cache_key = (tmdb_id, season_num)
@@ -395,13 +391,13 @@ async def _process_movies(
tmdb_info.poster_path, display_title, year, "movie", cover_dir tmdb_info.poster_path, display_title, year, "movie", cover_dir
) )
versions = {} torrents = {}
for item in items: for item in items:
relpath = make_relative_path(str(item.path), media_root) relpath = make_relative_path(str(item.path), media_root)
version = await _build_version_info(item, media_root) torrent = await _build_torrent_info(item, media_root)
versions[relpath] = version torrents[relpath] = torrent
sort_by_quality(list(versions.values())) sort_by_quality(list(torrents.values()))
# Queue showreel generation # Queue showreel generation
showreel_paths = [] showreel_paths = []
@@ -413,7 +409,7 @@ async def _process_movies(
versions[k].size or 0, versions[k].size or 0,
k k
)) ))
best_version = versions[best_relpath] best_version = torrents[best_relpath]
if best_version.playable_file: if best_version.playable_file:
abs_playable = ( abs_playable = (
str(Path(media_root) / best_version.playable_file) str(Path(media_root) / best_version.playable_file)
@@ -435,35 +431,19 @@ async def _process_movies(
tmdb_info.backdrop_path, display_title, year, "movie", cover_dir tmdb_info.backdrop_path, display_title, year, "movie", cover_dir
) )
version_timestamps = [v.newest for v in versions.values() if v.newest] version_timestamps = [v.added_at for v in torrents.values() if v.added_at]
newest = max(version_timestamps) if version_timestamps else None newest = max(version_timestamps) if version_timestamps else None
movie = Movie( movie = Movie(
id=item_id, id=item_id,
title=display_title, title=display_title,
original_title=tmdb_info.original_title, info=tmdb_info,
alternative_titles=tmdb_info.alternative_titles,
year=year, year=year,
newest=newest, newest=newest,
cover_path=make_relative_path(cover_path, media_root), cover_path=make_relative_path(cover_path, media_root),
backdrop_path=make_relative_path(backdrop_path, media_root), backdrop_path=make_relative_path(backdrop_path, media_root),
showreel_images=showreel_paths if showreel_paths else None, showreel_images=showreel_paths if showreel_paths else None,
versions=versions, torrents=torrents,
tmdb_id=tmdb_info.tmdb_id,
tmdb_title=tmdb_info.title,
rating=tmdb_info.rating,
vote_count=tmdb_info.vote_count,
overview=tmdb_info.overview,
genres=tmdb_info.genres,
release_date=tmdb_info.release_date,
runtime=tmdb_info.runtime,
status=tmdb_info.status,
tagline=tmdb_info.tagline,
poster_path=tmdb_info.poster_path,
similar=tmdb_info.similar,
keywords=tmdb_info.keywords,
cast=tmdb_info.cast,
director=tmdb_info.director,
) )
yield movie, showreel_task yield movie, showreel_task
@@ -478,24 +458,24 @@ async def _process_movies(
await find_cover_image(title, year, "movie", cover_dir) if fetch_covers else None await find_cover_image(title, year, "movie", cover_dir) if fetch_covers else None
) )
versions = {} torrents = {}
for item in items: for item in items:
relpath = make_relative_path(str(item.path), media_root) relpath = make_relative_path(str(item.path), media_root)
version = await _build_version_info(item, media_root) torrent = await _build_torrent_info(item, media_root)
versions[relpath] = version torrents[relpath] = torrent
sort_by_quality(list(versions.values())) sort_by_quality(list(torrents.values()))
showreel_paths = [] showreel_paths = []
showreel_task = None showreel_task = None
if generate_showreels and versions: if generate_showreels and torrents:
# Find the best version for showreel (highest quality) # Find the best version for showreel (highest quality)
best_relpath = max(versions.keys(), key=lambda k: ( best_relpath = max(torrents.keys(), key=lambda k: (
RESOLUTION_PRIORITY.get(versions[k].resolution or "", 0), RESOLUTION_PRIORITY.get(torrents[k].resolution or "", 0),
versions[k].size or 0, torrents[k].size or 0,
k k
)) ))
best_version = versions[best_relpath] best_version = torrents[best_relpath]
if best_version.playable_file and not best_version.playable_file.endswith(".bdmv"): if best_version.playable_file and not best_version.playable_file.endswith(".bdmv"):
abs_playable = ( abs_playable = (
str(Path(media_root) / best_version.playable_file) str(Path(media_root) / best_version.playable_file)
@@ -511,7 +491,7 @@ async def _process_movies(
) )
showreel_task = (abs_playable, media_folder, title) showreel_task = (abs_playable, media_folder, title)
version_timestamps = [v.newest for v in versions.values() if v.newest] version_timestamps = [v.added_at for v in torrents.values() if v.added_at]
newest = max(version_timestamps) if version_timestamps else None newest = max(version_timestamps) if version_timestamps else None
movie = Movie( movie = Movie(
@@ -521,7 +501,7 @@ async def _process_movies(
newest=newest, newest=newest,
cover_path=make_relative_path(cover_path, media_root), cover_path=make_relative_path(cover_path, media_root),
showreel_images=showreel_paths if showreel_paths else None, showreel_images=showreel_paths if showreel_paths else None,
versions=versions, torrents=torrents,
) )
yield movie, showreel_task yield movie, showreel_task
@@ -672,29 +652,12 @@ async def _process_series(
series = Series( series = Series(
id=series_id, id=series_id,
title=display_title, title=display_title,
original_title=tmdb_info.original_title, info=tmdb_info,
alternative_titles=different_titles if different_titles else None, alternative_titles=different_titles if different_titles else None,
newest=newest, newest=newest,
cover_path=make_relative_path(cover_path, media_root), cover_path=make_relative_path(cover_path, media_root),
backdrop_path=make_relative_path(backdrop_path, media_root), backdrop_path=make_relative_path(backdrop_path, media_root),
seasons=seasons_data, seasons=seasons_data,
tmdb_id=tmdb_info.tmdb_id,
tmdb_title=tmdb_info.title,
rating=tmdb_info.rating,
vote_count=tmdb_info.vote_count,
overview=tmdb_info.overview,
genres=tmdb_info.genres,
release_date=tmdb_info.release_date,
status=tmdb_info.status,
tagline=tmdb_info.tagline,
poster_path=tmdb_info.poster_path,
similar=tmdb_info.similar,
keywords=tmdb_info.keywords,
cast=tmdb_info.cast,
creators=tmdb_info.creators,
number_of_seasons=tmdb_info.number_of_seasons,
number_of_episodes=tmdb_info.number_of_episodes,
networks=tmdb_info.networks,
) )
yield series, ep_reel_tasks yield series, ep_reel_tasks
@@ -739,4 +702,3 @@ async def _process_series(
seasons=seasons_data, seasons=seasons_data,
) )
yield series, ep_reel_tasks yield series, ep_reel_tasks
+2 -2
View File
@@ -26,7 +26,8 @@ import msgspec
from aiopathlib import AsyncPath from aiopathlib import AsyncPath
from mediahive.hivescan.index_store import IndexStore from mediahive.hivescan.index_store import IndexStore
from mediahive.hivescan.indexer import _process_movies, _process_series from mediahive.hivescan.indexer import _process_movies, _process_series
from mediahive.hivescan.structs import MsgspecResponse, ScanRequest, StatusResponse, TaskInfo from mediahive.models.data import TaskInfo
from mediahive.models.protocol import MsgspecResponse, ScanRequest, StatusResponse
from mediahive.hivescan.models import ContentType, ParsedContent from mediahive.hivescan.models import ContentType, ParsedContent
from mediahive.hivescan.parsing import parse_download from mediahive.hivescan.parsing import parse_download
from mediahive.hivescan.scanning import categorize_downloads from mediahive.hivescan.scanning import categorize_downloads
@@ -494,4 +495,3 @@ def run(host: str = "0.0.0.0", port: int = 8421):
datefmt="%H:%M:%S", datefmt="%H:%M:%S",
) )
uvicorn.run(app, host=host, port=port, log_level="info") uvicorn.run(app, host=host, port=port, log_level="info")
-337
View File
@@ -1,337 +0,0 @@
"""
Typed structures for the hivescan/mediahive API, index state, and WebSocket protocol.
All API and state types are msgspec.Structs for fast serialization.
Internal scanning types (ContentHash, ParsedContent) remain in models.py.
"""
from __future__ import annotations
import msgspec
from fastapi.responses import Response
# ---------------------------------------------------------------------------
# Sub-types (shared by TMDb results and index items)
# ---------------------------------------------------------------------------
class CastMember(msgspec.Struct):
"""Actor/crew member."""
name: str
character: str | None = None
profile_path: str | None = None
class SimilarMedia(msgspec.Struct):
"""Pointer to a similar movie/series on TMDb."""
id: int
title: str
poster_path: str | None = None
# ---------------------------------------------------------------------------
# TMDb result types (returned by tmdb_client, consumed by indexer)
# ---------------------------------------------------------------------------
class TMDbEpisodeInfo(msgspec.Struct):
"""Episode metadata from TMDb."""
episode_number: int
season_number: int
name: str | None = None
overview: str | None = None
air_date: str | None = None
runtime: int | None = None
still_path: str | None = None
vote_average: float | None = None
vote_count: int | None = None
director: str | None = None
class TMDbSeasonInfo(msgspec.Struct):
"""Season metadata from TMDb."""
season_number: int
name: str | None = None
overview: str | None = None
air_date: str | None = None
poster_path: str | None = None
episode_count: int | None = None
episodes: list[TMDbEpisodeInfo] | None = None
class TMDbInfo(msgspec.Struct):
"""Full metadata result from TMDb (movies or series)."""
tmdb_id: int
title: str | None = None
original_title: str | None = None
alternative_titles: list[str] | None = None
rating: float | None = None
vote_count: int | None = None
overview: str | None = None
genres: list[str] | None = None
release_date: str | None = None
runtime: int | None = None
status: str | None = None
tagline: str | None = None
poster_path: str | None = None
backdrop_path: str | None = None
similar: list[SimilarMedia] | None = None
keywords: list[str] | None = None
cast: list[CastMember] | None = None
director: str | None = None
creators: list[str] | None = None
number_of_seasons: int | None = None
number_of_episodes: int | None = None
networks: list[str] | None = None
# ---------------------------------------------------------------------------
# Index item types (the state stored in IndexStore, sent over WS/API)
# ---------------------------------------------------------------------------
class MovieVersion(msgspec.Struct):
"""One release/torrent of a movie, keyed by relative torrent path."""
torrent_title: str | None = None
playable_file: str | None = None
resolution: str | None = None
quality: str | None = None
codec: str | None = None
audio: str | None = None
encoder: str | None = None
size: int | None = None
newest: int | None = None
class EpisodeRelease(msgspec.Struct):
"""One release/torrent file of an episode, keyed by relative torrent path."""
torrent_title: str | None = None
playable_file: str | None = None
resolution: str | None = None
quality: str | None = None
codec: str | None = None
audio: str | None = None
encoder: str | None = None
size: int | None = None
class Episode(msgspec.Struct):
"""Episode within a season."""
episode_number: int
name: str | None = None
overview: str | None = None
air_date: str | None = None
runtime: int | None = None
still_path: str | None = None
rating: float | None = None
director: str | None = None
reel_image: str | None = None
releases: dict[str, EpisodeRelease] = {}
class Season(msgspec.Struct):
"""Season within a series."""
season_number: int
name: str | None = None
overview: str | None = None
air_date: str | None = None
poster_path: str | None = None
episode_count: int | None = None
episodes: list[Episode] = []
class Movie(msgspec.Struct):
"""A movie in the index (one or more versions/releases)."""
id: str
title: str
original_title: str | None = None
alternative_titles: list[str] | None = None
year: int | None = None
newest: int | None = None
cover_path: str | None = None
backdrop_path: str | None = None
showreel_images: list[str] | None = None
versions: dict[str, MovieVersion] = {}
tmdb_id: int | None = None
tmdb_title: str | None = None
rating: float | None = None
vote_count: int | None = None
overview: str | None = None
genres: list[str] | None = None
release_date: str | None = None
runtime: int | None = None
status: str | None = None
tagline: str | None = None
poster_path: str | None = None
similar: list[SimilarMedia] | None = None
keywords: list[str] | None = None
cast: list[CastMember] | None = None
director: str | None = None
class Series(msgspec.Struct):
"""A TV series in the index."""
id: str
title: str
original_title: str | None = None
alternative_titles: list[str] | None = None
newest: int | None = None
cover_path: str | None = None
backdrop_path: str | None = None
seasons: list[Season] = []
tmdb_id: int | None = None
tmdb_title: str | None = None
rating: float | None = None
vote_count: int | None = None
overview: str | None = None
genres: list[str] | None = None
release_date: str | None = None
status: str | None = None
tagline: str | None = None
poster_path: str | None = None
similar: list[SimilarMedia] | None = None
keywords: list[str] | None = None
cast: list[CastMember] | None = None
creators: list[str] | None = None
number_of_seasons: int | None = None
number_of_episodes: int | None = None
networks: list[str] | None = None
# ---------------------------------------------------------------------------
# Snapshot (disk format for index.json)
# ---------------------------------------------------------------------------
class MediaStats(msgspec.Struct):
"""Aggregate counts for the index snapshot."""
total_movies: int = 0
total_movie_versions: int = 0
total_series: int = 0
total_series_episodes: int = 0
class IndexSnapshot(msgspec.Struct):
"""On-disk recovery snapshot of the full index."""
version: int = 6
generated_at: str = ""
media_root: str | None = None
stats: MediaStats = msgspec.UNSET # type: ignore[assignment]
movies: list[Movie] = []
series: list[Series] = []
def __post_init__(self):
if self.stats is msgspec.UNSET:
self.stats = MediaStats()
# ---------------------------------------------------------------------------
# WebSocket message types
# ---------------------------------------------------------------------------
class WsInitData(msgspec.Struct):
"""Payload of the init message."""
movies: list[Movie]
series: list[Series]
class WsInit(msgspec.Struct, tag="init"):
"""Full index sent on WS connect."""
data: WsInitData
class WsUpsert(msgspec.Struct, tag="upsert"):
"""Single item inserted or updated."""
kind: str
item: Movie | Series
class WsRemove(msgspec.Struct, tag="remove"):
"""Single item removed."""
kind: str
id: str
class TaskInfo(msgspec.Struct):
"""Progress info for a background task (scan, showreel, etc.)."""
id: str
status: str
progress: float = 0.0
detail: str = ""
class WsTask(msgspec.Struct, tag="task"):
"""Task progress broadcast."""
data: TaskInfo
# Union of all outbound WS messages (for documentation / future decoding)
WsMessage = WsInit | WsUpsert | WsRemove | WsTask
# ---------------------------------------------------------------------------
# API request / response types
# ---------------------------------------------------------------------------
class ScanRequest(msgspec.Struct):
"""POST /api/scan body."""
paths: list[str] | None = None
class StatusResponse(msgspec.Struct):
"""GET /api/status response."""
scanning: bool = False
movies: int = 0
series: int = 0
showreel_queue: int = 0
class PlayMediaRequest(msgspec.Struct):
"""POST /api/play body (mediahive server)."""
file_path: str = ""
class OpenFolderRequest(msgspec.Struct):
"""POST /api/open-folder body (mediahive server)."""
folder_path: str = ""
# ---------------------------------------------------------------------------
# FastAPI response helper
# ---------------------------------------------------------------------------
class MsgspecResponse(Response):
"""FastAPI response that serializes content with msgspec.json."""
media_type = "application/json; charset=utf-8"
def render(self, content: object) -> bytes:
return msgspec.json.encode(content)
+6 -9
View File
@@ -15,12 +15,10 @@ from typing import Dict, Optional
import httpx import httpx
from aiopathlib import AsyncPath from aiopathlib import AsyncPath
from mediahive.hivescan.structs import ( from mediahive.models.tmdb import (
CastMember, EpisodeInfo,
SimilarMedia, Info,
TMDbEpisodeInfo, SeasonInfo,
TMDbInfo,
TMDbSeasonInfo,
) )
# TMDb API configuration # TMDb API configuration
@@ -101,7 +99,7 @@ async def _save_to_cache(cache_path: Path, data: Optional[Dict]):
pass # Cache write failures are not critical pass # Cache write failures are not critical
# TMDbEpisodeInfo, TMDbSeasonInfo, TMDbInfo imported from mediahive.hivescan.structs # EpisodeInfo, SeasonInfo, Info imported from mediahive.models.tmdb
async def tmdb_api_request( async def tmdb_api_request(
@@ -192,7 +190,7 @@ async def fetch_season_details(
director = crew_member.get("name") director = crew_member.get("name")
break break
episode = TMDbEpisodeInfo( episode = EpisodeInfo(
episode_number=ep_data.get("episode_number", 0), episode_number=ep_data.get("episode_number", 0),
season_number=ep_data.get("season_number", season_number), season_number=ep_data.get("season_number", season_number),
name=ep_data.get("name"), name=ep_data.get("name"),
@@ -562,4 +560,3 @@ async def fetch_series_info(title: str) -> Optional[TMDbInfo]:
number_of_episodes=details.get("number_of_episodes"), number_of_episodes=details.get("number_of_episodes"),
networks=networks if networks else None, networks=networks if networks else None,
) )
+121
View File
@@ -0,0 +1,121 @@
"""
Data structures for mediahive and hivescan.
All types are msgspec.Structs for fast serialization.
"""
from __future__ import annotations
import msgspec
from .tmdb import CastMember, EpisodeInfo, Info, SimilarMedia
# ---------------------------------------------------------------------------
# Index item types (the state stored in IndexStore, sent over WS/API)
# ---------------------------------------------------------------------------
class Torrent(msgspec.Struct):
"""A torrent file, either for a movie or an episode."""
title: str | None = None
playable_file: str | None = None
resolution: str | None = None
quality: str | None = None
codec: str | None = None
audio: str | None = None
encoder: str | None = None
size: int | None = None
added_at: int | None = None
class Episode(msgspec.Struct):
"""Episode within a season."""
episode_number: int
name: str | None = None
overview: str | None = None
air_date: str | None = None
runtime: int | None = None
still_path: str | None = None
rating: float | None = None
director: str | None = None
reel_image: str | None = None
torrents: dict[str, Torrent] = {}
class Season(msgspec.Struct):
"""Season within a series."""
season_number: int
name: str | None = None
overview: str | None = None
air_date: str | None = None
poster_path: str | None = None
episode_count: int | None = None
episodes: list[Episode] = []
class Movie(msgspec.Struct):
"""A movie in the index (one or more versions/releases)."""
id: str
title: str | None = None
info: Info | None = None
year: int | None = None
newest: int | None = None
cover_path: str | None = None
backdrop_path: str | None = None
showreel_images: list[str] | None = None
torrents: dict[str, Torrent] = {}
class Series(msgspec.Struct):
"""A TV series in the index."""
id: str
title: str | None = None
info: Info | None = None
newest: int | None = None
cover_path: str | None = None
backdrop_path: str | None = None
seasons: list[Season] = []
# ---------------------------------------------------------------------------
# Snapshot (disk format for index.json)
# ---------------------------------------------------------------------------
class MediaStats(msgspec.Struct):
"""Aggregate counts for the index snapshot."""
total_movies: int = 0
total_movie_versions: int = 0
total_series: int = 0
total_series_episodes: int = 0
class IndexSnapshot(msgspec.Struct):
"""On-disk recovery snapshot of the full index."""
version: int = 6
generated_at: str = ""
media_root: str | None = None
stats: MediaStats = msgspec.UNSET # type: ignore[assignment]
movies: list[Movie] = []
series: list[Series] = []
def __post_init__(self):
if self.stats is msgspec.UNSET:
self.stats = MediaStats()
class TaskInfo(msgspec.Struct):
"""Progress info for a background task (scan, showreel, etc.)."""
id: str
status: str
progress: float = 0.0
detail: str = ""
+101
View File
@@ -0,0 +1,101 @@
"""
Protocol structures for API and WebSocket communication.
All types are msgspec.Structs for fast serialization.
"""
from __future__ import annotations
import msgspec
from fastapi.responses import Response
from .data import Movie, Series, TaskInfo
# ---------------------------------------------------------------------------
# WebSocket message types
# ---------------------------------------------------------------------------
class WsInitData(msgspec.Struct):
"""Payload of the init message."""
movies: list[Movie]
series: list[Series]
class WsInit(msgspec.Struct, tag="init"):
"""Full index sent on WS connect."""
data: WsInitData
class WsUpsert(msgspec.Struct, tag="upsert"):
"""Single item inserted or updated."""
kind: str
item: Movie | Series
class WsRemove(msgspec.Struct, tag="remove"):
"""Single item removed."""
kind: str
id: str
class WsTask(msgspec.Struct, tag="task"):
"""Task progress broadcast."""
data: TaskInfo
# Union of all outbound WS messages (for documentation / future decoding)
WsMessage = WsInit | WsUpsert | WsRemove | WsTask
# ---------------------------------------------------------------------------
# API request / response types
# ---------------------------------------------------------------------------
class ScanRequest(msgspec.Struct):
"""POST /api/scan body."""
paths: list[str] | None = None
class StatusResponse(msgspec.Struct):
"""GET /api/status response."""
scanning: bool = False
movies: int = 0
series: int = 0
showreel_queue: int = 0
class PlayMediaRequest(msgspec.Struct):
"""POST /api/play body (mediahive server)."""
file_path: str = ""
class OpenFolderRequest(msgspec.Struct):
"""POST /api/open-folder body (mediahive server)."""
folder_path: str = ""
# ---------------------------------------------------------------------------
# FastAPI response helper
# ---------------------------------------------------------------------------
class MsgspecResponse(Response):
"""FastAPI response that serializes content with msgspec.json."""
media_type = "application/json; charset=utf-8"
def render(self, content: object) -> bytes:
return msgspec.json.encode(content)</content>
<parameter name="filePath">c:\mediahive\mediahive\models\protocol.py
+90
View File
@@ -0,0 +1,90 @@
"""
TMDb data structures.
All types are msgspec.Structs for fast serialization.
"""
from __future__ import annotations
import msgspec
# ---------------------------------------------------------------------------
# Sub-types (shared by TMDb results and index items)
# ---------------------------------------------------------------------------
class CastMember(msgspec.Struct):
"""Actor/crew member."""
name: str
character: str | None = None
profile_path: str | None = None
class SimilarMedia(msgspec.Struct):
"""Pointer to a similar movie/series on TMDb."""
id: int
title: str
poster_path: str | None = None
# ---------------------------------------------------------------------------
# TMDb result types
# ---------------------------------------------------------------------------
class EpisodeInfo(msgspec.Struct):
"""Episode metadata from TMDb."""
episode_number: int
season_number: int
name: str | None = None
overview: str | None = None
air_date: str | None = None
runtime: int | None = None
still_path: str | None = None
vote_average: float | None = None
vote_count: int | None = None
director: str | None = None
class SeasonInfo(msgspec.Struct):
"""Season metadata from TMDb."""
season_number: int
name: str | None = None
overview: str | None = None
air_date: str | None = None
poster_path: str | None = None
episode_count: int | None = None
episodes: list[EpisodeInfo] | None = None
class Info(msgspec.Struct):
"""Full metadata result from TMDb (movies or series)."""
tmdb_id: int
title: str | None = None
original_title: str | None = None
alternative_titles: list[str] | None = None
rating: float | None = None
vote_count: int | None = None
overview: str | None = None
genres: list[str] | None = None
release_date: str | None = None
runtime: int | None = None
status: str | None = None
tagline: str | None = None
poster_path: str | None = None
backdrop_path: str | None = None
similar: list[SimilarMedia] | None = None
keywords: list[str] | None = None
cast: list[CastMember] | None = None
director: str | None = None
creators: list[str] | None = None
number_of_seasons: int | None = None
number_of_episodes: int | None = None
networks: list[str] | None = None</content>
<parameter name="filePath">c:\mediahive\mediahive\models\tmdb.py
+1 -2
View File
@@ -18,7 +18,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from fastapi_vue import Frontend from fastapi_vue import Frontend
from mediahive.hivescan.structs import PlayMediaRequest, OpenFolderRequest from mediahive.models.protocol import PlayMediaRequest, OpenFolderRequest
from mediahive.__main__ import DEVMODE from mediahive.__main__ import DEVMODE
@@ -262,4 +262,3 @@ async def serve_media_file(file_path: str):
# Serve the Vue frontend (needs to be last if SPA catch-all is used) # Serve the Vue frontend (needs to be last if SPA catch-all is used)
frontend.route(app, "/") frontend.route(app, "/")