Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/lokalise-download.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: Download Lokalise translations
on:
on:
schedule:
- cron: "0 2 * * 2"
jobs:
Expand All @@ -10,14 +10,14 @@ jobs:
- name: Lokalise CLI
run: curl -sfL https://raw.githubusercontent.com/lokalise/lokalise-cli-2-go/master/install.sh | sh
- name: Pull
env:
env:
VAR_LOKALISE_API_TOKEN: ${{ secrets.LOKALISE_RO_API_TOKEN }}
VAR_LOKALISE_PROJECT_ID: ${{ secrets.LOKALISE_PROJECT_ID }}
VAR_LANGUAGES: zh_CN,cs,da,nl,en_au,en_GB,fr,de,el_GR,hu_HU,it,ko_KR,nb,pl,pt_BR,pt_PT,ru_RU,sl_SI,sr,sr_Latn,es,sv_SE,uk_UA
run: |
./bin/lokalise2 --token ${{ env.VAR_LOKALISE_API_TOKEN }} --project-id ${{ env.VAR_LOKALISE_PROJECT_ID }} file download --filter-langs ${{ env.VAR_LANGUAGES }} --format json --export-empty-as skip --export-sort first_added --placeholder-format printf --plural-format array --indentation 4sp --directory-prefix src/translations --replace-breaks=false
- name: Create Pull Request
env:
env:
GH_TOKEN: ${{ github.token }}
GITHUB_NEW_BRANCH_NAME: Lokalise-${{ github.run_id }}${{ github.run_attempt }}
run: |
Expand Down
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
yarn lint
yarn test:run
yarn test:run
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,4 @@ If you wish to assist in translating Music Assistant into a language that it cur

---

[![A project from the Open Home Foundation](https://www.openhomefoundation.org/badges/ohf-project.png)](https://www.openhomefoundation.org/)
[![A project from the Open Home Foundation](https://www.openhomefoundation.org/badges/ohf-project.png)](https://www.openhomefoundation.org/)
1 change: 1 addition & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ const completeInitialization = async () => {
// Fetch library counts
store.libraryArtistsCount = await api.getLibraryArtistsCount();
store.libraryAlbumsCount = await api.getLibraryAlbumsCount();
store.libraryGenresCount = await api.getLibraryGenresCount();
store.libraryPlaylistsCount = await api.getLibraryPlaylistsCount();
store.libraryRadiosCount = await api.getLibraryRadiosCount();
store.libraryTracksCount = await api.getLibraryTracksCount();
Expand Down
341 changes: 341 additions & 0 deletions src/components/GenreDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,341 @@
<script setup lang="ts">
import type { Genre } from "@/plugins/api/interfaces";
import { ImageType } from "@/plugins/api/interfaces";
import { useGenresStore } from "@/stores/genres";
import { store } from "@/plugins/store";
import { authManager } from "@/plugins/auth";
import { eventbus } from "@/plugins/eventbus";
import { computed, ref, watch, onBeforeUnmount } from "vue";
import { getMediaItemImage, getImageURL } from "./MediaItemThumb.vue";
import { useI18n } from "vue-i18n";

const genresStore = useGenresStore();
const {
createGenre,
updateGenre,
addAlias,
removeAlias,
deleteGenre,
mergeGenres,
searchGenres,
} = genresStore;
const { t } = useI18n();

const props = defineProps<{
modelValue: boolean;
genre?: Genre; // If provided, we are editing
}>();

const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void;
(e: "saved"): void;
}>();

const name = ref("");
const linkedGenres = ref<(string | Genre)[]>([]);
const loading = ref(false);
const image = ref<string | null>(null);
const searchInput = ref("");
const updateImage = ref(false);

const isEdit = computed(() => !!props.genre);
const title = computed(() =>
isEdit.value ? "Edit Genre" : "Create New Genre",
);

const items = computed(() => {
const all = [...genresStore.genres.value, ...genresStore.searchResults.value];
// Filter out the current genre if editing
const currentId = props.genre?.item_id;
const filtered = all.filter((g) => g.item_id !== currentId);

// Deduplicate
const unique = new Map();
for (const g of filtered) {
unique.set(g.item_id, g);
}
return Array.from(unique.values());
});

watch(searchInput, (val) => {
if (val && val.length > 1) {
searchGenres(val);
}
});

const imageUrlInput = computed({
get: () =>
image.value && !image.value.startsWith("data:") ? image.value : "",
set: (val) => {
image.value = val;
},
});

const displayImage = computed(() => {
if (!image.value) return "";
if (image.value.startsWith("data:")) return image.value;
const imgObj = {
path: image.value,
provider: image.value.startsWith("http") ? "http" : "builtin",
type: ImageType.THUMB,
remotely_accessible: image.value.startsWith("http"),
};
return getImageURL(imgObj);
});

watch(
() => props.modelValue,
(val) => {
store.dialogActive = val;
if (val) {
if (props.genre) {
name.value = props.genre.name;
linkedGenres.value = props.genre.aliases
? [...props.genre.aliases]
: [];
const img = getMediaItemImage(props.genre, ImageType.THUMB);
image.value = img ? img.path : null;
} else {
name.value = "";
linkedGenres.value = [];
image.value = null;
}
updateImage.value = false;
searchInput.value = "";
if (genresStore.genres.value.length === 0) {
genresStore.loadGenres();
}
}
},
{ immediate: true },
);

onBeforeUnmount(() => {
store.dialogActive = false;
});

const onSave = async () => {
if (!name.value) return;
loading.value = true;
try {
let genreToUpdate: Genre | undefined;

// Separate strings (aliases) and objects (merge candidates)
const newAliases = linkedGenres.value.filter(
(x): x is string => typeof x === "string",
);
const genresToMerge = linkedGenres.value.filter(
(x): x is Genre => typeof x === "object",
);

if (isEdit.value && props.genre) {
// Update name and optionally images
genreToUpdate = { ...props.genre, name: name.value };

// Handle image updates - only update if explicitly requested
if (image.value && updateImage.value) {
if (!genreToUpdate.metadata) genreToUpdate.metadata = {};
const isDataUrl = image.value.startsWith("data:");
genreToUpdate.metadata.images = [
{
type: ImageType.THUMB,
path: image.value,
provider: isDataUrl ? "builtin" : "http",
remotely_accessible: !isDataUrl,
},
];
} else if (!updateImage.value) {
// Remove images from update payload to keep existing
if (genreToUpdate.metadata) {
delete genreToUpdate.metadata.images;
}
}

await updateGenre(genreToUpdate);

// Handle aliases
const originalAliases = props.genre.aliases || [];

const added = newAliases.filter((a) => !originalAliases.includes(a));
const removed = originalAliases.filter((a) => !newAliases.includes(a));

const genreId = parseInt(props.genre.item_id);
for (const alias of added) {
await addAlias(genreId, alias);
}
for (const alias of removed) {
await removeAlias(genreId, alias);
}
} else {
genreToUpdate = await createGenre(name.value);

// If image provided, update the genre with image metadata
if (genreToUpdate && image.value) {
const isDataUrl = image.value.startsWith("data:");
if (!genreToUpdate.metadata) genreToUpdate.metadata = {};
genreToUpdate.metadata.images = [
{
type: ImageType.THUMB,
path: image.value,
provider: isDataUrl ? "builtin" : "http",
remotely_accessible: !isDataUrl,
},
];
await updateGenre(genreToUpdate);
}

// For new genres, we need to add the aliases after creation
if (genreToUpdate && newAliases.length > 0) {
const genreId = parseInt(genreToUpdate.item_id);
for (const alias of newAliases) {
await addAlias(genreId, alias);
}
}
}

// Handle Merges
if (genresToMerge.length > 0 && genreToUpdate) {
const sourceIds = genresToMerge.map((g) => parseInt(g.item_id));
const targetId = parseInt(genreToUpdate.item_id);
await mergeGenres(sourceIds, targetId);
}

emit("saved");
emit("update:modelValue", false);
eventbus.emit("refreshItems", "genres");
} finally {
loading.value = false;
}
};

const onDelete = async () => {
if (!props.genre) return;
if (confirm(t("are_you_sure"))) {
loading.value = true;
try {
await deleteGenre(parseInt(props.genre.item_id));
emit("saved");
emit("update:modelValue", false);
eventbus.emit("refreshItems", "genres");
} finally {
loading.value = false;
}
}
};
</script>

<template>
<v-dialog
:model-value="modelValue"
max-width="600"
@update:model-value="emit('update:modelValue', $event)"
>
<v-card @keydown.stop>
<v-card-title>{{ title }}</v-card-title>
<v-card-text>
<v-alert v-if="!authManager.isAdmin()" type="warning" class="mb-4">
{{ $t("admin_required") }}
</v-alert>
<v-text-field
v-model="name"
:label="$t('new_genre_name')"
required
autofocus
:disabled="!authManager.isAdmin()"
@keyup.enter="onSave"
/>

<v-combobox
v-model="linkedGenres"
v-model:search="searchInput"
:items="items"
:loading="genresStore.loading.value"
item-title="name"
item-value="item_id"
:label="$t('genres.linked_genres')"
return-object
multiple
chips
closable-chips
hide-selected
clearable
placeholder="Search for genres or type new alias..."
class="mb-2"
:disabled="!authManager.isAdmin()"
>
<template #selection="{ item, index }">
<v-chip
v-if="index < 5"
closable
size="small"
@click:close="linkedGenres.splice(index, 1)"
>
{{ typeof item.value === "string" ? item.value : item.title }}
</v-chip>
<span
v-if="index === 5"
class="text-grey text-caption align-self-center"
>
(+{{ linkedGenres.length - 5 }} others)
</span>
</template>
</v-combobox>

<v-radio-group
v-if="isEdit"
v-model="updateImage"
:disabled="!authManager.isAdmin()"
class="mb-2"
>
<v-radio :label="$t('genres.keep_existing_images')" :value="false" />
<v-radio :label="$t('genres.update_image')" :value="true" />
</v-radio-group>

<v-text-field
v-if="updateImage || !isEdit"
v-model="imageUrlInput"
:label="$t('image_url')"
prepend-icon="mdi-image"
clearable
:hint="
image && image.startsWith('data:') ? 'Image uploaded from file' : ''
"
persistent-hint
:disabled="!authManager.isAdmin()"
/>

<div
v-if="displayImage && (updateImage || !isEdit)"
class="mb-4 d-flex justify-center"
>
<v-img :src="displayImage" max-height="200" max-width="200" contain />
</div>
</v-card-text>
<v-card-actions>
<v-btn
v-if="isEdit && authManager.isAdmin()"
color="error"
variant="text"
@click="onDelete"
>
{{ $t("settings.delete") }}
</v-btn>
<v-spacer />
<v-btn
color="grey"
variant="text"
@click="emit('update:modelValue', false)"
>
Cancel
</v-btn>
<v-btn
color="primary"
:loading="loading"
:disabled="!authManager.isAdmin()"
@click="onSave"
>Save</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
</template>
Loading
Loading