diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 04ade01e..1129349f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -309,32 +309,22 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
+ with:
+ fetch-depth: 0 # Full history needed for git-cliff
- - name: Extract changelog for version
+ - name: Generate changelog with git-cliff
id: changelog
+ uses: orhun/git-cliff-action@v4
+ with:
+ config: cliff.toml
+ args: --latest --strip header
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ OUTPUT: /tmp/changelog.txt
+
+ - name: Show generated changelog
run: |
- VERSION=${{ needs.get-version.outputs.version }}
- VERSION_NUM=${VERSION#v} # Remove 'v' prefix
-
- echo "Looking for version: $VERSION_NUM"
-
- # Extract changelog section for this version using sed
- # Find the line with version, then print until next version header or end
- CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
-
- # If no changelog found, use default message
- if [ -z "$CHANGELOG" ]; then
- echo "No changelog found for version $VERSION_NUM"
- CHANGELOG="See CHANGELOG.md for details."
- else
- echo "Found changelog content"
- # Remove trailing --- separator if present (CHANGELOG uses --- between versions)
- CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
- fi
-
- # Save to file for multiline support
- echo "$CHANGELOG" > /tmp/changelog.txt
- echo "Extracted changelog:"
+ echo "Generated changelog:"
cat /tmp/changelog.txt
- name: Download Android APK
@@ -352,15 +342,13 @@ jobs:
- name: Prepare release body
run: |
VERSION=${{ needs.get-version.outputs.version }}
- cat > /tmp/release_body.txt << 'HEADER'
- ### What's New
- HEADER
-
- cat /tmp/changelog.txt >> /tmp/release_body.txt
-
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
-
+
+ # Start with git-cliff changelog
+ cp /tmp/changelog.txt /tmp/release_body.txt
+
+ # Append download section
cat >> /tmp/release_body.txt << FOOTER
---
@@ -404,6 +392,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
- name: Download Android APK
uses: actions/download-artifact@v7
@@ -417,52 +407,40 @@ jobs:
name: ios-ipa
path: ./release
- - name: Extract changelog for version
+ - name: Generate changelog with git-cliff for Telegram
+ uses: orhun/git-cliff-action@v4
+ with:
+ config: cliff.toml
+ args: --latest --strip all
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ OUTPUT: /tmp/cliff_tg.txt
+
+ - name: Convert changelog for Telegram
id: changelog
run: |
- VERSION=${{ needs.get-version.outputs.version }}
- VERSION_NUM=${VERSION#v}
-
- # Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
- # Use tr -d '\r' to handle CRLF line endings from Windows
- FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
-
- echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
- echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
-
- if [ -z "$FULL_CHANGELOG" ]; then
- CHANGELOG="See release notes on GitHub for details."
+ if [ ! -s /tmp/cliff_tg.txt ]; then
+ echo "See release notes on GitHub for details." > /tmp/changelog.txt
else
- # Convert GitHub Markdown to Telegram HTML:
- # - **text** → text
- # - `code` → code
- # - ### Header → Header
- # - Escape HTML special chars first
- # - Remove > blockquote prefix
- CHANGELOG=$(echo "$FULL_CHANGELOG" | \
- sed 's/^> //' | \
+ # Convert Markdown to Telegram HTML
+ CHANGELOG=$(cat /tmp/cliff_tg.txt | \
+ sed '/^\*\*Full Changelog\*\*/d' | \
+ sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
+ sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
sed 's/&/\&/g' | \
sed 's/\</g' | \
sed 's/>/\>/g' | \
- sed 's/`\([^`]*\)`/\1<\/code>/g' | \
sed 's/\*\*\([^*]*\)\*\*/\1<\/b>/g' | \
sed 's/^### \(.*\)$/\1<\/b>/g' | \
sed 's/^## \(.*\)$/\1<\/b>/g' | \
- sed 's/^- /• /g' | \
- sed 's/^ - / ◦ /g')
-
- # Take first 2500 characters, then cut at last complete line
+ sed 's/^- /• /g')
+
+ # Truncate for Telegram 4096 char limit
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
-
- # Check if truncated
- FULL_LEN=${#FULL_CHANGELOG}
- if [ $FULL_LEN -gt 2500 ]; then
- CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
- fi
+ echo "$CHANGELOG" > /tmp/changelog.txt
fi
- echo "$CHANGELOG" > /tmp/changelog.txt
- echo "DEBUG: Final changelog:"
+ echo "Telegram changelog:"
cat /tmp/changelog.txt
- name: Send to Telegram Channel
diff --git a/cliff.toml b/cliff.toml
new file mode 100644
index 00000000..0509818b
--- /dev/null
+++ b/cliff.toml
@@ -0,0 +1,105 @@
+# git-cliff configuration for SpotiFLAC Mobile
+# https://git-cliff.org/docs/configuration
+
+[changelog]
+# Template for the changelog body
+body = """
+{%- macro remote_url() -%}
+ https://github.com/zarzet/SpotiFLAC-Mobile
+{%- endmacro -%}
+
+{% if version %}\
+ ## {{ version | trim_start_matches(pat="v") }}
+{% else %}\
+ ## Unreleased
+{% endif %}\
+
+{% for group, commits in commits | group_by(attribute="group") %}
+ ### {{ group | striptags | trim | upper_first }}
+ {% for commit in commits %}
+ - {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
+ {{ commit.message | upper_first }}\
+ {% if commit.github.pr_number %} \
+ ([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
+ {% endif %}\
+ {%- if commit.github.username %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
+ {%- endfor %}
+{% endfor %}
+
+{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
+
+ ### New Contributors
+{%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
+ * @{{ contributor.username }} made their first contribution
+ {%- if contributor.pr_number %} in \
+ [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
+ {%- endif %}
+{%- endfor %}
+{%- endif -%}
+
+{% if version %}
+ {% if previous.version %}
+ **Full Changelog**: [{{ previous.version }}...{{ version }}]({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})
+ {% endif %}
+{% else -%}
+ {% raw %}\n{% endraw %}
+{% endif %}
+"""
+# Remove leading and trailing whitespace
+trim = true
+
+[git]
+# Parse conventional commits
+conventional_commits = true
+filter_unconventional = true
+
+# Process each line of a commit as an individual commit
+split_commits = false
+
+# Regex for preprocessing the commit messages
+commit_preprocessors = [
+ # Remove PR number from message (we add it back via GitHub integration)
+ { pattern = '\(#(\d+)\)', replace = '' },
+ # Strip conventional commit prefix for cleaner messages
+ # (group header already shows the type)
+]
+
+# Regex for parsing and grouping commits
+commit_parsers = [
+ # Skip noise: translation commits from Crowdin
+ { message = "^New translations", skip = true },
+ { message = "^Update source file", skip = true },
+ # Skip merge commits
+ { message = "^Merge", skip = true },
+ # Skip version bump commits
+ { message = "^v\\d+", skip = true },
+ { message = "^chore: update VirusTotal", skip = true },
+
+ # Group by conventional commit type
+ { message = "^feat", group = "New Features" },
+ { message = "^fix", group = "Bug Fixes" },
+ { message = "^perf", group = "Performance" },
+ { message = "^refactor", group = "Refactoring" },
+ { message = "^doc", group = "Documentation" },
+ { message = "^style", group = "Styling" },
+ { message = "^test", group = "Testing" },
+ { message = "^chore\\(deps\\)", group = "Dependencies" },
+ { message = "^chore\\(l10n\\)", skip = true },
+ { message = "^chore|^ci", group = "Chores" },
+]
+
+# Protect breaking changes from being skipped
+protect_breaking_commits = true
+
+# Filter out commits by matching patterns
+filter_commits = false
+
+# Tag pattern for version detection
+tag_pattern = "v[0-9].*"
+
+# Sort commits by newest first
+sort_commits = "newest"
+
+[remote.github]
+owner = "zarzet"
+repo = "SpotiFLAC-Mobile"
diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart
index fe9f21ad..1dd10dd7 100644
--- a/lib/providers/local_library_provider.dart
+++ b/lib/providers/local_library_provider.dart
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
+import 'package:spotiflac_android/utils/path_match_keys.dart';
final _log = AppLogger('LocalLibrary');
@@ -193,74 +194,11 @@ class LocalLibraryNotifier extends Notifier {
await _loadFromDatabase();
}
- Set _buildPathMatchKeys(String? filePath) {
- final raw = filePath?.trim() ?? '';
- if (raw.isEmpty) return const {};
-
- final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw;
- final keys = {};
-
- void addNormalized(String value) {
- final trimmed = value.trim();
- if (trimmed.isEmpty) return;
- keys.add(trimmed);
- keys.add(trimmed.toLowerCase());
- if (trimmed.contains('\\')) {
- final slash = trimmed.replaceAll('\\', '/');
- keys.add(slash);
- keys.add(slash.toLowerCase());
- }
- if (trimmed.contains('%')) {
- try {
- final decoded = Uri.decodeFull(trimmed);
- keys.add(decoded);
- keys.add(decoded.toLowerCase());
- } catch (_) {}
- }
-
- Uri? parsed;
- try {
- parsed = Uri.parse(trimmed);
- } catch (_) {}
-
- if (parsed != null && parsed.hasScheme) {
- final noQueryOrFragment = parsed.replace(query: null, fragment: null);
- keys.add(noQueryOrFragment.toString());
- keys.add(noQueryOrFragment.toString().toLowerCase());
-
- if (parsed.scheme == 'file') {
- try {
- final fileOnly = parsed.toFilePath();
- if (fileOnly.isNotEmpty) {
- keys.add(fileOnly);
- keys.add(fileOnly.toLowerCase());
- if (fileOnly.contains('\\')) {
- final slash = fileOnly.replaceAll('\\', '/');
- keys.add(slash);
- keys.add(slash.toLowerCase());
- }
- }
- } catch (_) {}
- }
- } else if (trimmed.startsWith('/')) {
- try {
- final asFileUri = Uri.file(trimmed).toString();
- keys.add(asFileUri);
- keys.add(asFileUri.toLowerCase());
- } catch (_) {}
- }
- }
-
- addNormalized(cleaned);
-
- return keys;
- }
-
bool _isDownloadedPath(String? filePath, Set downloadedPathKeys) {
if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) {
return false;
}
- final candidateKeys = _buildPathMatchKeys(filePath);
+ final candidateKeys = buildPathMatchKeys(filePath);
for (final key in candidateKeys) {
if (downloadedPathKeys.contains(key)) {
return true;
@@ -322,8 +260,9 @@ class LocalLibraryNotifier extends Notifier {
String? resolvedPath;
bool didStartSecurityAccess = false;
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
- resolvedPath =
- await PlatformBridge.startAccessingIosBookmark(iosBookmark);
+ resolvedPath = await PlatformBridge.startAccessingIosBookmark(
+ iosBookmark,
+ );
if (resolvedPath != null) {
didStartSecurityAccess = true;
_log.i('Started iOS security-scoped access: $resolvedPath');
@@ -354,7 +293,7 @@ class LocalLibraryNotifier extends Notifier {
};
final downloadedPathKeys = {};
for (final path in allHistoryPaths) {
- downloadedPathKeys.addAll(_buildPathMatchKeys(path));
+ downloadedPathKeys.addAll(buildPathMatchKeys(path));
}
_log.i(
'Excluding ${allHistoryPaths.length} downloaded files from library scan '
@@ -834,8 +773,9 @@ class LocalLibraryNotifier extends Notifier {
Future cleanupMissingFiles({String? iosBookmark}) async {
bool didStartSecurityAccess = false;
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
- final resolved =
- await PlatformBridge.startAccessingIosBookmark(iosBookmark);
+ final resolved = await PlatformBridge.startAccessingIosBookmark(
+ iosBookmark,
+ );
if (resolved != null) {
didStartSecurityAccess = true;
}
diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart
index 1aa3bad3..e05429c9 100644
--- a/lib/screens/queue_tab.dart
+++ b/lib/screens/queue_tab.dart
@@ -29,6 +29,7 @@ import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
import 'package:spotiflac_android/screens/local_album_screen.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
+import 'package:spotiflac_android/utils/path_match_keys.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
enum LibraryItemSource { downloaded, local }
@@ -2112,10 +2113,22 @@ class _QueueTabState extends ConsumerState {
if (count > 1) albumCount++;
}
+ final downloadedPathKeys = {};
+ for (final item in items) {
+ downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath));
+ }
+
+ final dedupedLocalItems = localItems
+ .where((item) {
+ final localPathKeys = buildPathMatchKeys(item.filePath);
+ return !localPathKeys.any(downloadedPathKeys.contains);
+ })
+ .toList(growable: false);
+
// Calculate local library stats
final localAlbumCounts = {};
final localAlbumMap = >{};
- for (final item in localItems) {
+ for (final item in dedupedLocalItems) {
final key =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1;
@@ -2841,8 +2854,24 @@ class _QueueTabState extends ConsumerState {
.map((item) => UnifiedLibraryItem.fromLocalLibrary(item))
.toList(growable: false);
- final merged = [...unifiedDownloaded, ...unifiedLocal]
- ..sort((a, b) => b.addedAt.compareTo(a.addedAt));
+ final downloadedPathKeys = {};
+ for (final item in unifiedDownloaded) {
+ downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath));
+ }
+
+ final dedupedUnifiedLocal = [];
+ for (final item in unifiedLocal) {
+ final localPathKeys = buildPathMatchKeys(item.filePath);
+ final overlapsDownloaded = localPathKeys.any(downloadedPathKeys.contains);
+ if (!overlapsDownloaded) {
+ dedupedUnifiedLocal.add(item);
+ }
+ }
+
+ final merged = [
+ ...unifiedDownloaded,
+ ...dedupedUnifiedLocal,
+ ]..sort((a, b) => b.addedAt.compareTo(a.addedAt));
_unifiedItemsCache[filterMode] = _UnifiedCacheEntry(
historyItems: historyItems,
diff --git a/lib/utils/path_match_keys.dart b/lib/utils/path_match_keys.dart
new file mode 100644
index 00000000..ace140dd
--- /dev/null
+++ b/lib/utils/path_match_keys.dart
@@ -0,0 +1,104 @@
+import 'dart:io';
+
+const _androidStoragePathAliases = [
+ '/storage/emulated/0',
+ '/storage/emulated/legacy',
+ '/storage/self/primary',
+ '/sdcard',
+ '/mnt/sdcard',
+];
+
+Set buildPathMatchKeys(String? filePath) {
+ final raw = filePath?.trim() ?? '';
+ if (raw.isEmpty) return const {};
+
+ final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7).trim() : raw;
+ if (cleaned.isEmpty) return const {};
+
+ final keys = {};
+ final visited = {};
+
+ void addNormalized(String value) {
+ final trimmed = value.trim();
+ if (trimmed.isEmpty) return;
+ if (!visited.add(trimmed)) return;
+
+ keys.add(trimmed);
+ keys.add(trimmed.toLowerCase());
+
+ if (trimmed.contains('\\')) {
+ final slash = trimmed.replaceAll('\\', '/');
+ if (slash != trimmed) {
+ addNormalized(slash);
+ }
+ }
+
+ if (trimmed.contains('%')) {
+ try {
+ final decoded = Uri.decodeFull(trimmed);
+ if (decoded != trimmed) {
+ addNormalized(decoded);
+ }
+ } catch (_) {}
+ }
+
+ Uri? parsed;
+ try {
+ parsed = Uri.parse(trimmed);
+ } catch (_) {}
+
+ if (parsed != null && parsed.hasScheme) {
+ final withoutQueryOrFragment = parsed.replace(
+ query: null,
+ fragment: null,
+ );
+ final uriString = withoutQueryOrFragment.toString();
+ keys.add(uriString);
+ keys.add(uriString.toLowerCase());
+
+ if (parsed.scheme == 'file') {
+ try {
+ addNormalized(parsed.toFilePath());
+ } catch (_) {}
+ }
+ } else if (trimmed.startsWith('/')) {
+ try {
+ final asFileUri = Uri.file(trimmed).toString();
+ keys.add(asFileUri);
+ keys.add(asFileUri.toLowerCase());
+ } catch (_) {}
+ }
+
+ if (Platform.isAndroid) {
+ for (final alias in _androidEquivalentPaths(trimmed)) {
+ if (alias != trimmed) {
+ addNormalized(alias);
+ }
+ }
+ }
+ }
+
+ addNormalized(cleaned);
+ return keys;
+}
+
+Iterable _androidEquivalentPaths(String path) {
+ final normalized = path.replaceAll('\\', '/');
+ final lower = normalized.toLowerCase();
+ String? suffix;
+
+ for (final prefix in _androidStoragePathAliases) {
+ if (lower == prefix) {
+ suffix = '';
+ break;
+ }
+ final withSlash = '$prefix/';
+ if (lower.startsWith(withSlash)) {
+ suffix = normalized.substring(prefix.length);
+ break;
+ }
+ }
+
+ if (suffix == null) return const [];
+ return _androidStoragePathAliases.map((prefix) => '$prefix$suffix');
+}