Skip to content

Commit

Permalink
Merge pull request #300 from bohning/custom-data
Browse files Browse the repository at this point in the history
Custom data
  • Loading branch information
RumovZ authored Oct 19, 2024
2 parents a160e87 + bae96d7 commit cc22d37
Show file tree
Hide file tree
Showing 19 changed files with 406 additions and 27 deletions.
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,27 @@

## Features

- Custom data may be added to downloaded songs as key-value pairs.
- The download path can now be customized using a dedicated template syntax (see
_Settings_). The template must contain at least two components, which are separated
using slashes. The last component specifies the filename, excluding its extension.
Example: `:year: / :artist: / :title: / song` will store files like
`1975/Queen/Bohemian Rhapsody/song.txt` and so on.
- You can even reference custom data with `:*my_key:`, which resolves to the value
associated with `my_key` for a given song.
- Searches can be saved to the sidebar.
- A single saved search may be made the default to automatically apply it on startup.
- You can subscribe to saved searches to automatically download matches when new songs
are found on USDB.
- Comments can now be posted on songs. Each comment includes a message and a rating.
- Comments can now be posted on songs. Each comment includes a message and a rating.
Ratings can be negative, neutral, or positive, with neutral being the default.
- The VP9 codec can be excluded for mp4 video containers (see _Settings_).
- Tags such as artist, title and year are now also written to the video file (mp4 only).
- Some text file fixes are now optional and can be configured in the settings:
- fix linebreaks (disabled | USDX style | YASS style)
- fix first words capitalization (disabled | enabled)
- fix spaces (after words | before words)
- We're trying out a hook system to make the syncer extensible. See addons/README.md.

## Developer notes

Expand Down
60 changes: 60 additions & 0 deletions src/usdb_syncer/custom_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Data structure for storing user defined custom data."""

import builtins
import collections.abc
from collections import defaultdict

from usdb_syncer import db


class CustomData:
"""Dict of custom data."""

_data: dict[str, str]
_options: defaultdict[str, builtins.set[str]] | None = None
FORBIDDEN_KEY_CHARS = '?"<>|*.:/\\'

@classmethod
def value_options(cls, key: str) -> tuple[str, ...]:
if cls._options is None:
cls._options = db.get_custom_data_map()
# pylingt bug: https://github.com/pylint-dev/pylint/issues/9515
return tuple(cls._options[key]) # pylint: disable=unsubscriptable-object

@classmethod
def key_options(cls) -> tuple[str, ...]:
if cls._options is None:
cls._options = db.get_custom_data_map()
return tuple(cls._options) # pylint: disable=unsubscriptable-object

@classmethod
def is_valid_key(cls, key: str) -> bool:
return (
bool(key)
and key.strip() == key
and not any(c in key for c in cls.FORBIDDEN_KEY_CHARS)
)

def __init__(self, data: dict[str, str] | None = None) -> None:
self._data = data.copy() if data else {}

def get(self, key: str) -> str | None:
return self._data.get(key)

def set(self, key: str, value: str | None) -> None:
if value is None:
if key in self._data:
del self._data[key]
else:
self._data[key] = value
if self._options is not None:
self._options[key].add(value) # pylint: disable=unsubscriptable-object

def items(self) -> collections.abc.ItemsView[str, str]:
return self._data.items()

def inner(self) -> dict[str, str]:
return self._data.copy()

def __eq__(self, value: object) -> bool:
return isinstance(value, CustomData) and self._data == value._data
43 changes: 42 additions & 1 deletion src/usdb_syncer/db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import threading
import time
import traceback
from collections import defaultdict
from pathlib import Path
from typing import Any, Generator, Iterable, Iterator, assert_never, cast

Expand All @@ -19,7 +20,7 @@
from usdb_syncer import SongId, SyncMetaId, errors, logger
from usdb_syncer.utils import AppPaths

SCHEMA_VERSION = 4
SCHEMA_VERSION = 5

# https://www.sqlite.org/limits.html
_SQL_VARIABLES_LIMIT = 32766
Expand Down Expand Up @@ -744,6 +745,46 @@ def delete_sync_metas(ids: tuple[SyncMetaId, ...]) -> None:
)


@attrs.define(frozen=True, slots=False)
class CustomMetaDataParams:
"""Parameters for inserting or updating a resource file."""

sync_meta_id: SyncMetaId
key: str
value: str


def upsert_custom_meta_data(params: Iterable[CustomMetaDataParams]) -> None:
stmt = _SqlCache.get("upsert_custom_meta_data.sql")
_DbState.connection().executemany(stmt, (p.__dict__ for p in params))


def delete_custom_meta_data(ids: Iterable[SyncMetaId]) -> None:
for batch in batched(ids, _SQL_VARIABLES_LIMIT):
id_str = ", ".join("?" for _ in range(len(batch)))
_DbState.connection().execute(
f"DELETE FROM custom_meta_data WHERE sync_meta_id IN ({id_str})", batch
)


def get_custom_data(sync_meta_id: SyncMetaId) -> dict[str, str]:
return dict(
_DbState.connection().execute(
"SELECT key, value FROM custom_meta_data WHERE sync_meta_id = ?",
(int(sync_meta_id),),
)
)


def get_custom_data_map() -> defaultdict[str, set[str]]:
data: defaultdict[str, set[str]] = defaultdict(set)
for key, value in _DbState.connection().execute(
"SELECT DISTINCT key, value FROM custom_meta_data"
):
data[key].add(value)
return data


### ResourceFile


Expand Down
11 changes: 11 additions & 0 deletions src/usdb_syncer/db/sql/5_migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
BEGIN;

CREATE TABLE custom_meta_data (
sync_meta_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (sync_meta_id, key),
FOREIGN KEY (sync_meta_id) REFERENCES sync_meta (sync_meta_id) ON DELETE CASCADE
);

END;
7 changes: 7 additions & 0 deletions src/usdb_syncer/db/sql/upsert_custom_meta_data.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
INSERT INTO
custom_meta_data
VALUES
(:sync_meta_id, :key, :value) ON CONFLICT (sync_meta_id, key) DO
UPDATE
SET
value = :value
45 changes: 45 additions & 0 deletions src/usdb_syncer/gui/custom_data_dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Dialog for adding custom sync meta data."""

from typing import Callable

from PySide6 import QtWidgets

from usdb_syncer.custom_data import CustomData
from usdb_syncer.gui.forms.CustomDataDialog import Ui_Dialog

FORBIDDEN_CHARS = '?"<>|*.:/\\'


class CustomDataDialog(Ui_Dialog, QtWidgets.QDialog):
"""Dialog with about info and credits."""

def __init__(
self,
parent: QtWidgets.QWidget,
on_accept: Callable[[str, str], None],
key: str | None = None,
) -> None:
super().__init__(parent=parent)
self.setupUi(self)
self._on_accept = on_accept
if key:
self.edit_key.setText(key)

def accept(self) -> None:
key = self.edit_key.text().strip()
value = self.edit_value.text().strip()
if not key or not value:
warning = "Both key and value must be supplied!"
QtWidgets.QMessageBox.warning(
self, "Warning", "Both key and value must be supplied!"
)
elif not CustomData.is_valid_key(key):
warning = (
"Key must not contain any of these characters: "
+ CustomData.FORBIDDEN_KEY_CHARS
)
else:
self._on_accept(key, value)
super().accept()
return
QtWidgets.QMessageBox.warning(self, "Warning", warning)
101 changes: 101 additions & 0 deletions src/usdb_syncer/gui/forms/CustomDataDialog.ui
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>353</width>
<height>145</height>
</rect>
</property>
<property name="windowTitle">
<string>Dialog</string>
</property>
<layout class="QFormLayout" name="formLayout">
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Key</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="edit_key">
<property name="maxLength">
<number>100</number>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Value</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLineEdit" name="edit_value"/>
</item>
<item row="4" column="1">
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item row="5" column="0" colspan="2">
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::StandardButton::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>Dialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>Dialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>
10 changes: 10 additions & 0 deletions src/usdb_syncer/gui/forms/MainWindow.ui
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,15 @@
<property name="toolTipsVisible">
<bool>true</bool>
</property>
<widget class="QMenu" name="menu_custom_data">
<property name="title">
<string>Custom Data</string>
</property>
<property name="icon">
<iconset resource="../resources/resources.qrc">
<normaloff>:/icons/drawer.png</normaloff>:/icons/drawer.png</iconset>
</property>
</widget>
<addaction name="action_songs_download"/>
<addaction name="action_songs_abort"/>
<addaction name="action_show_in_usdb"/>
Expand All @@ -236,6 +245,7 @@
<addaction name="action_open_song_folder"/>
<addaction name="action_delete"/>
<addaction name="action_pin"/>
<addaction name="menu_custom_data"/>
</widget>
<widget class="QMenu" name="menu_local">
<property name="title">
Expand Down
1 change: 1 addition & 0 deletions src/usdb_syncer/gui/mw.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def _setup_toolbar(self) -> None:
(self.action_pin, self.table.set_pin_selected_songs),
):
action.triggered.connect(func)
self.menu_custom_data.aboutToShow.connect(self.table.build_custom_data_menu)

def _setup_shortcuts(self) -> None:
gui_utils.set_shortcut("Ctrl+.", self, lambda: DebugConsole(self).show())
Expand Down
Binary file added src/usdb_syncer/gui/resources/drawer.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/usdb_syncer/gui/resources/resources.qrc
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<file>database.png</file>
<file>document-export.png</file>
<file>document-import.png</file>
<file>drawer.png</file>
<file>drive-download.png</file>
<file>edge.png</file>
<file>edition.png</file>
Expand Down
2 changes: 2 additions & 0 deletions src/usdb_syncer/gui/settings_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ def _setup_path_template(self) -> None:
)
for item in path_template.PathTemplatePlaceholder:
self.comboBox_placeholder.addItem(str(item), item)
for key in path_template.PathTemplateCustomPlaceholder.options():
self.comboBox_placeholder.addItem(str(key))

def _on_path_template_changed(self, text: str) -> None:
try:
Expand Down
Loading

0 comments on commit cc22d37

Please sign in to comment.