Skip to content

Commit

Permalink
Changes for v1.2 (#112)
Browse files Browse the repository at this point in the history
* get/set tags with Field + fix zero fill when writing track numbers

* add __eq__ on filters + improve pretty printer output + add BasicLocalCollection

* add tests

* add move and rename functionality for local tracks + expand workflows to test py 3.13

* fix linting issues

* fix python version in worflows

* fix python version in worflows

* use classproperty decorator

* fix linting issues

* implement classproperty
  • Loading branch information
geo-martino authored Dec 2, 2024
1 parent 8770341 commit 91b4a0a
Show file tree
Hide file tree
Showing 40 changed files with 488 additions and 102 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,14 @@ jobs:
matrix:
os: [ ubuntu-latest ]
name: [ 🐧 Linux ]
python-version: [ 3.12 ]
python-version: [ 3.12, 3.13 ]
include:
- os: windows-latest
name: 🪟 Windows
python-version: 3.12
python-version: 3.13
- os: macos-latest
name: 🍎 Mac
python-version: 3.12
python-version: 3.13
steps:
- name: 🛒 Checkout
uses: actions/checkout@v4
Expand Down
1 change: 1 addition & 0 deletions .idea/musify.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions docs/info/release-history.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,30 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en>`_,
and this project adheres to `Semantic Versioning <https://semver.org/spec/v2.0.0.html>`_


1.2.0
=====

Added
-----
* Can now get tags from any :py:class:`.MusifyItem` and set tags on any :py:class:`.LocalTrack`
using the relevant :py:class:`.Field` enums
* Equality comparison methods to all implementations of :py:class:`.Filter`
* :py:class:`.BasicLocalCollection` for creating and managing arbitrary local collections
* :py:class:`.MusifyEnum` now displayed correctly when outputting to ``json`` on :py:class:`.PrettyPrinter` objects
* :py:meth:`.LocalTrack.move` and :py:meth:`.LocalTrack.rename` methods to handle moving the file on the disk.
* Set the ``path`` and ``filename`` properties on a :py:class:`.LocalTrack` to defer the movement of a file on the disk.
Setting a new path in this way does not immediately move a file.
Instead, the file will be moved when :py:meth:`.LocalTrack.save` is called with a ``path`` type
tag field as an argument.

Changed
-------
* Track number zero fill amount is now calculated from the track total value
when writing track tags on :py:class:`.LocalTrack`
* Simplified ``dict`` output from :py:class:`.FilterComparers`
* Field names displayed as lower case in ``dict`` output on relevant :py:class:`.PrettyPrinter` implementations


1.1.10
======

Expand Down
7 changes: 6 additions & 1 deletion musify/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,13 @@ def __eq__(self, item: MusifyItem):
def __ne__(self, item: MusifyItem):
return not self.__eq__(item)

def __getitem__(self, key: str) -> Any:
def __getitem__(self, key: str | TagField) -> Any:
"""Get the value of a given attribute key"""
if isinstance(key, TagField):
try:
key = next(iter(sorted(key.to_tag())))
except StopIteration:
key = key.name.lower()
return getattr(self, key)


Expand Down
4 changes: 3 additions & 1 deletion musify/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
from dataclasses import field, dataclass
from typing import Self

from musify.types import UnitIterable, MusifyEnum
from aiorequestful.types import UnitIterable

from musify.types import MusifyEnum


class Field(MusifyEnum):
Expand Down
4 changes: 2 additions & 2 deletions musify/libraries/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def length(self):
lengths = {getattr(item, "length", None) for item in self.items}
return sum({length for length in lengths if length}) if lengths else None

def __init__(self, name: str, items: Collection[T]):
super().__init__()
def __init__(self, name: str, items: Collection[T], *args, **kwargs):
super().__init__(*args, **kwargs)
self._name = name
self._items = to_collection(items, list)
4 changes: 2 additions & 2 deletions musify/libraries/core/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from pathlib import Path
from typing import Any, SupportsIndex, Self

from aiorequestful.types import UnitSequence
from yarl import URL

from musify.base import MusifyObject, MusifyItem, HasLength
Expand All @@ -17,7 +18,6 @@
from musify.file.base import File
from musify.libraries.remote.core import RemoteResponse
from musify.processors.sort import ShuffleMode, ItemSorter
from musify.types import UnitSequence

type ItemGetterTypes = str | URL | MusifyItem | Path | File | RemoteResponse

Expand Down Expand Up @@ -167,7 +167,7 @@ def append(self, __item: T, allow_duplicates: bool = True) -> None:
if allow_duplicates or __item not in self.items:
self.items.append(__item)

def extend(self, __items: Iterable[T], allow_duplicates: bool = True) -> None:
def extend(self, __items: Collection[T], allow_duplicates: bool = True) -> None:
"""Append many items to the items in this collection"""
if not self._validate_item_type(__items):
raise MusifyTypeError([type(i).__name__ for i in __items])
Expand Down
26 changes: 11 additions & 15 deletions musify/libraries/core/object.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from musify.exception import MusifyTypeError
from musify.libraries.core.collection import MusifyCollection
from musify.libraries.remote.core.types import RemoteObjectType
from musify.utils import classproperty


class Track(MusifyItem, HasLength, metaclass=ABCMeta):
Expand All @@ -24,9 +25,8 @@ class Track(MusifyItem, HasLength, metaclass=ABCMeta):
__slots__ = ()
__attributes_ignore__ = ("name",)

# noinspection PyPropertyDefinition
@classmethod
@property
# noinspection PyMethodParameters
@classproperty
def kind(cls) -> RemoteObjectType:
"""The type of remote object associated with this class"""
return RemoteObjectType.TRACK
Expand Down Expand Up @@ -177,9 +177,8 @@ class Playlist[T: Track](MusifyCollection[T], metaclass=ABCMeta):
__attributes_classes__ = MusifyCollection
__attributes_ignore__ = ("items",)

# noinspection PyPropertyDefinition
@classmethod
@property
# noinspection PyMethodParameters
@classproperty
def kind(cls) -> RemoteObjectType:
"""The type of remote object associated with this class"""
return RemoteObjectType.PLAYLIST
Expand Down Expand Up @@ -307,9 +306,8 @@ def name(self):
"""The library name"""
raise NotImplementedError

# noinspection PyPropertyDefinition
@classmethod
@property
# noinspection PyMethodParameters
@classproperty
def source(cls) -> str:
"""The type of library loaded"""
return cls.__name__.replace("Library", "")
Expand Down Expand Up @@ -486,9 +484,8 @@ class Album[T: Track](MusifyCollection[T], metaclass=ABCMeta):
__attributes_classes__ = MusifyCollection
__attributes_ignore__ = ("name", "items")

# noinspection PyPropertyDefinition
@classmethod
@property
# noinspection PyMethodParameters
@classproperty
def kind(cls) -> RemoteObjectType:
"""The type of remote object associated with this class"""
return RemoteObjectType.ALBUM
Expand Down Expand Up @@ -610,9 +607,8 @@ class Artist[T: (Track, Album)](MusifyCollection[T], metaclass=ABCMeta):
__attributes_classes__ = MusifyCollection
__attributes_ignore__ = ("name", "items")

# noinspection PyPropertyDefinition
@classmethod
@property
# noinspection PyMethodParameters
@classproperty
def kind(cls) -> RemoteObjectType:
"""The type of remote object associated with this class"""
return RemoteObjectType.ARTIST
Expand Down
27 changes: 26 additions & 1 deletion musify/libraries/local/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from pathlib import Path
from typing import Any, Self

from aiorequestful.types import UnitCollection, UnitIterable

from musify.field import Fields, TagField, TagFields
from musify.file.exception import UnexpectedPathError
from musify.libraries.core.collection import MusifyCollection
Expand All @@ -22,7 +24,6 @@
from musify.libraries.local.track.field import LocalTrackField
from musify.libraries.remote.core.wrangle import RemoteDataWrangler
from musify.logger import MusifyLogger
from musify.types import UnitCollection, UnitIterable
from musify.utils import get_most_common_values, to_collection, align_string, get_max_width

_max_str = "z" * 50
Expand Down Expand Up @@ -175,6 +176,30 @@ def merge_tracks(self, tracks: Collection[Track], tags: UnitIterable[TagField] =
self.logger.print_line()


class BasicLocalCollection[T: LocalTrack](LocalCollection[T]):

__slots__ = ("_name", "_tracks")

@property
def name(self):
"""The name of this collection"""
return self._name

@property
def tracks(self) -> list[T]:
return self._tracks

@property
def length(self):
lengths = {getattr(item, "length", None) for item in self.items}
return sum({length for length in lengths if length}) if lengths else None

def __init__(self, name: str, tracks: Collection[T], remote_wrangler: RemoteDataWrangler = None):
super().__init__(remote_wrangler=remote_wrangler)
self._name = name
self._tracks = to_collection(tracks, list)


class LocalCollectionFiltered[T: LocalItem](LocalCollection[T], metaclass=ABCMeta):
"""
Generic class for storing and filtering on a collection of local tracks
Expand Down
10 changes: 5 additions & 5 deletions musify/libraries/local/library/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from pathlib import Path
from typing import Any

from aiorequestful.types import UnitCollection, UnitIterable

from musify.base import Result
from musify.exception import MusifyError
from musify.file.path_mapper import PathMapper, PathStemMapper
Expand All @@ -22,8 +24,7 @@
from musify.processors.base import Filter
from musify.processors.filter import FilterDefinedList
from musify.processors.sort import ItemSorter
from musify.types import UnitCollection, UnitIterable
from musify.utils import align_string, get_max_width, to_collection
from musify.utils import align_string, get_max_width, to_collection, classproperty

type RestoreTracksType = Iterable[Mapping[str, Any]] | Mapping[str | Path, Mapping[str, Any]]

Expand Down Expand Up @@ -75,9 +76,8 @@ def name(self) -> str:
def name(self, value: str):
self._name = value

# noinspection PyPropertyDefinition
@classmethod
@property
# noinspection PyMethodParameters
@classproperty
def source(cls) -> str:
"""The type of local library loaded"""
return cls.__name__.replace("Library", "")
Expand Down
3 changes: 2 additions & 1 deletion musify/libraries/local/library/musicbee.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from typing import Any
from urllib.parse import quote, unquote

from aiorequestful.types import Number

from musify.file.base import File
from musify.file.exception import FileDoesNotExistError, UnexpectedPathError
from musify.file.path_mapper import PathMapper, PathStemMapper
Expand All @@ -20,7 +22,6 @@
from musify.libraries.local.track import LocalTrack
from musify.libraries.remote.core.wrangle import RemoteDataWrangler
from musify.processors.base import Filter
from musify.types import Number
from musify.utils import to_collection, required_modules_installed

try:
Expand Down
41 changes: 25 additions & 16 deletions musify/libraries/local/track/_tags/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@
Implements all functionality pertaining to writing and deleting metadata/tags/properties for a :py:class:`LocalTrack`.
"""
from abc import ABCMeta, abstractmethod
from collections.abc import Mapping, Collection, Callable
from collections.abc import Mapping, Collection, Callable, MutableMapping
from dataclasses import dataclass
from typing import Any

import mutagen
from aiorequestful.types import UnitIterable

from musify.base import Result
from musify.libraries.core.object import Track
from musify.libraries.local.track._tags.base import TagProcessor
from musify.libraries.local.track.field import LocalTrackField as Tags
from musify.types import UnitIterable
from musify.utils import to_collection


Expand All @@ -22,7 +22,7 @@ class SyncResultTrack(Result):
#: Were changes to the file on the disk made.
saved: bool
#: Map of the tag updated and the index of the condition it satisfied to be updated.
updated: Mapping[Tags, int]
updated: MutableMapping[Tags, int]


class TagWriter[T: mutagen.FileType](TagProcessor, metaclass=ABCMeta):
Expand Down Expand Up @@ -114,6 +114,8 @@ def write(
tags: set[Tags] = to_collection(tags, set)
if Tags.ALL in tags:
tags = set(Tags.all(only_tags=True))
else:
tags = {tag for tag in tags if tag in set(Tags.all(only_tags=True))}

if any(f in tags for f in {Tags.TRACK, Tags.TRACK_NUMBER, Tags.TRACK_TOTAL}):
tags -= {Tags.TRACK_NUMBER, Tags.TRACK_TOTAL}
Expand Down Expand Up @@ -316,14 +318,18 @@ def _write_track(self, track: Track, dry_run: bool = True) -> bool:
tag_id_number = next(iter(self.tag_map.track_number), None)
tag_id_total = next(iter(self.tag_map.track_total), None)

if tag_id_number != tag_id_total and track.track_total is not None:
number_updated = self.write_tag(tag_id_number, str(track.track_number).zfill(2), dry_run)
total_updated = self.write_tag(tag_id_total, str(track.track_total).zfill(2), dry_run)
zero_fill = len(str(track.track_total)) if track.track_total is not None else 1
track_number = str(track.track_number).zfill(zero_fill)
track_total = str(track.track_total) if track.track_total is not None else None

if tag_id_number != tag_id_total and track_total is not None:
number_updated = self.write_tag(tag_id_number, track_number, dry_run)
total_updated = self.write_tag(tag_id_total, track_total, dry_run)
return number_updated or total_updated
elif track.track_total is not None:
tag_value = self.num_sep.join([str(track.track_number).zfill(2), str(track.track_total).zfill(2)])
elif track_total is not None:
tag_value = self.num_sep.join([track_number, track_total])
else:
tag_value = str(track.track_number).zfill(2)
tag_value = track_number

return self.write_tag(tag_id_number, tag_value, dry_run)

Expand Down Expand Up @@ -500,16 +506,19 @@ def _write_disc(self, track: Track, dry_run: bool = True) -> bool:
"""
tag_id_number = next(iter(self.tag_map.disc_number), None)
tag_id_total = next(iter(self.tag_map.disc_total), None)
fill = len(str(track.disc_total)) if track.disc_total is not None else 1

if tag_id_number != tag_id_total and track.disc_total is not None:
number_updated = self.write_tag(tag_id_number, str(track.disc_number).zfill(fill), dry_run)
total_updated = self.write_tag(tag_id_total, str(track.disc_total).zfill(fill), dry_run)
zero_fill = len(str(track.disc_total)) if track.disc_total is not None else 1
disc_number = str(track.disc_number).zfill(zero_fill)
disc_total = str(track.disc_total) if track.disc_total is not None else None

if tag_id_number != tag_id_total and disc_total is not None:
number_updated = self.write_tag(tag_id_number, disc_number, dry_run)
total_updated = self.write_tag(tag_id_total, disc_total, dry_run)
return number_updated or total_updated
elif track.disc_total is not None:
tag_value = self.num_sep.join([str(track.disc_number).zfill(fill), str(track.disc_total).zfill(fill)])
elif disc_total is not None:
tag_value = self.num_sep.join([disc_number, disc_total])
else:
tag_value = str(track.disc_number).zfill(fill)
tag_value = disc_number

return self.write_tag(tag_id_number, tag_value, dry_run)

Expand Down
Loading

0 comments on commit 91b4a0a

Please sign in to comment.