diff --git a/packages/common/src/services/remote-config/feature-flags.ts b/packages/common/src/services/remote-config/feature-flags.ts index dcbe3f3f562..e4e93e0d227 100644 --- a/packages/common/src/services/remote-config/feature-flags.ts +++ b/packages/common/src/services/remote-config/feature-flags.ts @@ -55,7 +55,8 @@ export enum FeatureFlags { BUY_USDC_VIA_SOL = 'buy_usdc_via_sol', IOS_USDC_PURCHASE_ENABLED = 'ios_usdc_purchase_enabled', SCHEDULED_RELEASES = 'scheduled_releases', - BUY_WITH_COINFLOW = 'buy_with_coinflow' + BUY_WITH_COINFLOW = 'buy_with_coinflow', + EDIT_ALBUMS = 'edit_albums' } type FlagDefaults = Record @@ -126,5 +127,6 @@ export const flagDefaults: FlagDefaults = { [FeatureFlags.BUY_USDC_VIA_SOL]: false, [FeatureFlags.IOS_USDC_PURCHASE_ENABLED]: true, [FeatureFlags.SCHEDULED_RELEASES]: false, - [FeatureFlags.BUY_WITH_COINFLOW]: false + [FeatureFlags.BUY_WITH_COINFLOW]: false, + [FeatureFlags.EDIT_ALBUMS]: false } diff --git a/packages/discovery-provider/integration_tests/tasks/entity_manager/test_playlist_entity_manager.py b/packages/discovery-provider/integration_tests/tasks/entity_manager/test_playlist_entity_manager.py index 0550038df00..c947d05a1c4 100644 --- a/packages/discovery-provider/integration_tests/tasks/entity_manager/test_playlist_entity_manager.py +++ b/packages/discovery-provider/integration_tests/tasks/entity_manager/test_playlist_entity_manager.py @@ -1034,3 +1034,144 @@ def get_events_side_effect(_, tx_receipt): ) assert total_changes == 0 + + +def test_index_add_tracks_to_collections(app, mocker): + "Tests adding tracks to albums and playlists. Tracks not owned by the album's owner should be ignored. Playlists allow all." + + # setup db and mocked txs + with app.app_context(): + db = get_db() + web3 = Web3() + challenge_event_bus: ChallengeEventBus = setup_challenge_bus() + update_task = UpdateTask(web3, challenge_event_bus) + + test_metadata = { + "AlbumTracklistUpdate": { + "playlist_contents": { + "track_ids": [ + {"time": 1660927554, "track": 1}, + {"time": 1660927554, "track": 2}, + ] + } + }, + "PlaylistTracklistUpdate": { + "playlist_contents": { + "track_ids": [ + {"time": 1660927554, "track": 1}, + {"time": 1660927554, "track": 2}, + ] + } + }, + } + + album_tracklist_update_json = json.dumps(test_metadata["AlbumTracklistUpdate"]) + playlist_tracklist_update_json = json.dumps( + test_metadata["PlaylistTracklistUpdate"] + ) + + tx_receipts = { + "UpdateAlbumTracklistUpdate": [ + { + "args": AttributeDict( + { + "_entityId": PLAYLIST_ID_OFFSET, + "_entityType": "Playlist", + "_userId": 1, + "_action": "Update", + "_metadata": f'{{"cid": "AlbumTracklistUpdate", "data": {album_tracklist_update_json}}}', + "_signer": "user1wallet", + } + ) + } + ], + "UpdatePlaylistTracklistUpdate": [ + { + "args": AttributeDict( + { + "_entityId": PLAYLIST_ID_OFFSET + 1, + "_entityType": "Playlist", + "_userId": 1, + "_action": "Update", + "_metadata": f'{{"cid": "PlaylistTracklistUpdate", "data": {playlist_tracklist_update_json}}}', + "_signer": "user1wallet", + } + ) + } + ], + } + + entity_manager_txs = [ + AttributeDict({"transactionHash": update_task.web3.to_bytes(text=tx_receipt)}) + for tx_receipt in tx_receipts + ] + + def get_events_side_effect(_, tx_receipt): + return tx_receipts[tx_receipt["transactionHash"].decode("utf-8")] + + mocker.patch( + "src.tasks.entity_manager.entity_manager.get_entity_manager_events_tx", + side_effect=get_events_side_effect, + autospec=True, + ) + + entities = { + "users": [ + {"user_id": 1, "handle": "user-1", "wallet": "user1wallet"}, + ], + "tracks": [ + {"track_id": 1, "owner_id": 1}, + {"track_id": 2, "owner_id": 2}, + ], + "playlists": [ + { + "playlist_id": PLAYLIST_ID_OFFSET, + "playlist_owner_id": 1, + "is_album": True, + }, + { + "playlist_id": PLAYLIST_ID_OFFSET + 1, + "playlist_owner_id": 1, + "is_album": False, + }, + ], + } + populate_mock_db(db, entities) + with db.scoped_session() as session: + # index transactions + entity_manager_update( + update_task, + session, + entity_manager_txs, + block_number=0, + block_timestamp=1585336422, + block_hash=hex(0), + ) + + # Validate album got only the single owned track + all_playlists: List[Playlist] = session.query(Playlist).all() + assert len(all_playlists) == 2 + + album: Playlist = ( + session.query(Playlist) + .filter(Playlist.playlist_id == PLAYLIST_ID_OFFSET) + .first() + ) + assert album.is_album == True + assert len(album.playlist_contents["track_ids"]) == 1 + assert album.playlist_contents["track_ids"] <= [ + {"time": 1585336422, "track": 1, "metadata_time": 1660927554} + ] + + # Validate playlist got both tracks + playlist: Playlist = ( + session.query(Playlist) + .filter(Playlist.playlist_id == PLAYLIST_ID_OFFSET + 1) + .first() + ) + assert playlist.is_album == False + assert len(playlist.playlist_contents["track_ids"]) == 2 + assert playlist.playlist_contents["track_ids"] <= [ + {"metadata_time": 1660927554, "time": 1585336422, "track": 1}, + {"metadata_time": 1660927554, "time": 1585336422, "track": 2}, + ] diff --git a/packages/discovery-provider/src/tasks/entity_manager/entities/playlist.py b/packages/discovery-provider/src/tasks/entity_manager/entities/playlist.py index fd3e7041923..a0ad019364d 100644 --- a/packages/discovery-provider/src/tasks/entity_manager/entities/playlist.py +++ b/packages/discovery-provider/src/tasks/entity_manager/entities/playlist.py @@ -304,13 +304,22 @@ def delete_playlist(params: ManageEntityParameters): params.add_record(params.entity_id, deleted_playlist) -def process_playlist_contents(playlist_record, playlist_metadata, block_integer_time): +def process_playlist_contents( + playlist_record, playlist_metadata, block_integer_time, existing_track_records +): updated_tracks = [] for track in playlist_metadata["playlist_contents"]["track_ids"]: track_id = track["track"] metadata_time = track["time"] index_time = block_integer_time # default to current block for new tracks + track_metadata = existing_track_records.get(track_id) + if playlist_record.is_album and ( + not track_metadata + or (track_metadata.owner_id != playlist_record.playlist_owner_id) + ): + continue + previous_playlist_tracks = playlist_record.playlist_contents["track_ids"] for previous_track in previous_playlist_tracks: previous_track_id = previous_track["track"] @@ -346,10 +355,13 @@ def process_playlist_data_event( # Update the playlist_record when the corresponding field exists # in playlist_metadata if key == "playlist_contents": - if not playlist_metadata.get(key) or playlist_record.is_album: + if not playlist_metadata.get(key): continue playlist_record.playlist_contents = process_playlist_contents( - playlist_record, playlist_metadata, block_integer_time + playlist_record, + playlist_metadata, + block_integer_time, + params.existing_records["Track"], ) elif key in playlist_metadata: if key in immutable_playlist_fields and params.action == Action.UPDATE: diff --git a/packages/discovery-provider/src/tasks/index_payment_router.py b/packages/discovery-provider/src/tasks/index_payment_router.py index 271acd6061b..d917662f8bc 100644 --- a/packages/discovery-provider/src/tasks/index_payment_router.py +++ b/packages/discovery-provider/src/tasks/index_payment_router.py @@ -4,23 +4,22 @@ from decimal import Decimal from typing import List, Optional, Tuple, TypedDict, Union, cast -from src.models.users.payment_router import PaymentRouterTx from redis import Redis from solders.instruction import CompiledInstruction from solders.message import Message from solders.pubkey import Pubkey -from solders.token.associated import get_associated_token_address from solders.rpc.responses import GetTransactionResp +from solders.token.associated import get_associated_token_address from solders.transaction_status import UiTransactionStatusMeta - from sqlalchemy import and_, desc from sqlalchemy.orm.session import Session from src.challenges.challenge_event import ChallengeEvent from src.challenges.challenge_event_bus import ChallengeEventBus from src.exceptions import SolanaTransactionFetchError -from src.models.tracks.track_price_history import TrackPriceHistory from src.models.tracks.track import Track +from src.models.tracks.track_price_history import TrackPriceHistory +from src.models.users.payment_router import PaymentRouterTx from src.models.users.usdc_purchase import PurchaseType, USDCPurchase from src.models.users.usdc_transactions_history import ( USDCTransactionMethod, @@ -36,9 +35,7 @@ USDC_DECIMALS, ) from src.solana.solana_client_manager import SolanaClientManager -from src.solana.solana_helpers import ( - get_base_address, -) +from src.solana.solana_helpers import get_base_address from src.tasks.celery_app import celery from src.utils.cache_solana_program import ( cache_latest_sol_db_tx, diff --git a/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx b/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx index 64877447044..4314ac8ffb9 100644 --- a/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx +++ b/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx @@ -10,7 +10,8 @@ import { CollectionTrack, CollectionPageTrackRecord, CollectionsPageType, - DogEarType + DogEarType, + FeatureFlags } from '@audius/common' import { @@ -23,6 +24,7 @@ import Page from 'components/page/Page' import { SuggestedTracks } from 'components/suggested-tracks' import { Tile } from 'components/tile' import { TracksTable, TracksTableColumn } from 'components/tracks-table' +import { useFlag } from 'hooks/useRemoteConfig' import { computeCollectionMetadataProps } from 'pages/collection-page/store/utils' import styles from './CollectionPage.module.css' @@ -118,6 +120,8 @@ const CollectionPage = ({ onClickReposts, onClickFavorites }: CollectionPageProps) => { + const { isEnabled: isEditAlbumsEnabled } = useFlag(FeatureFlags.EDIT_ALBUMS) + // TODO: Consider dynamic lineups, esp. for caching improvement. const [dataSource, playingIndex] = tracks.status === Status.SUCCESS @@ -264,7 +268,7 @@ const CollectionPage = ({ userId !== null && userId === playlistOwnerId && allowReordering && - !isAlbum + (!isAlbum || isEditAlbumsEnabled) } removeText={`${messages.remove} ${ isAlbum ? messages.type.album : messages.type.playlist