penguin/loopdeck

A simple player that support A-B looping for FiiO M11S

commit 6431ba356de91bd0c1da2d9c50139b899a54cece

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-11-01T11:41:10Z
subjectFallback on filenames for ビ/ビ
commit 6431ba356de91bd0c1da2d9c50139b899a54cece
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-11-01T11:41:10Z

    Fallback on filenames for ビ/ビ
---
 .../net/astropenguin/loopdeck/MusicLibrary.java    | 23 +++++-
 scripts/test-cue-filenames.py                      | 89 ++++++++++++++++++++++
 2 files changed, 108 insertions(+), 4 deletions(-)

diff --git a/app/src/main/java/net/astropenguin/loopdeck/MusicLibrary.java b/app/src/main/java/net/astropenguin/loopdeck/MusicLibrary.java
index 75bbc7b..7fadd8f 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/MusicLibrary.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/MusicLibrary.java
@@ -8,6 +8,7 @@ import android.provider.DocumentsContract;
 import java.io.*;
 import java.nio.*;
 import java.nio.charset.*;
+import java.text.Normalizer;
 import java.util.*;
 
 /** Shared local/remote folder and album reader. All methods run off the UI thread. */
@@ -50,7 +51,7 @@ final class MusicLibrary {
         } else {
             // Prefer a same-named sheet, then sheets that explicitly reference this image.
             String stem=entry.name.replaceFirst("\\.[^.]+$","");
-            folder.sort((x,y) -> Boolean.compare(!x.name.equalsIgnoreCase(stem+".cue"),!y.name.equalsIgnoreCase(stem+".cue")));
+            folder.sort((x,y) -> Boolean.compare(!sameFilename(x.name,stem+".cue"),!sameFilename(y.name,stem+".cue")));
             for (Entry candidate:folder) if (isCue(candidate)) {
                 try { found=cueTracks(readCue(context,candidate),folder,entry); } catch (IOException ignored) { continue; }
                 if (!found.isEmpty()) break;
@@ -93,6 +94,21 @@ final class MusicLibrary {
             catch (CharacterCodingException invalidUtf8) { return new String(bytes,Charset.forName("windows-31j")); }
         }
     }
+    private static boolean sameFilename(String a,String b) {
+        return Normalizer.normalize(a,Normalizer.Form.NFC).equalsIgnoreCase(
+            Normalizer.normalize(b,Normalizer.Form.NFC));
+    }
+    private static Entry findAudio(List<Entry> folder,String name) {
+        // Keep existing literal matches first; macOS copies can decompose kana accents.
+        for (Entry candidate:folder)
+            if (candidate.audio && candidate.name.equalsIgnoreCase(name)) return candidate;
+        Entry found=null;
+        for (Entry candidate:folder) if (candidate.audio && sameFilename(candidate.name,name)) {
+            if (found!=null) return null; // Do not guess between canonically equivalent files.
+            found=candidate;
+        }
+        return found;
+    }
     private static ArrayList<Track> cueTracks(String text,List<Entry> folder,Entry image) {
         return cueTracks(text,folder,image,false);
     }
@@ -102,9 +118,8 @@ final class MusicLibrary {
             Entry target=null;
             String name=part.file.replace('\\','/');
             while (name.startsWith("./")) name=name.substring(2);
-            if (image!=null && (embedded || name.isEmpty() || name.equalsIgnoreCase(image.name))) target=image;
-            if (image==null) for (Entry candidate:folder)
-                if (candidate.audio && candidate.name.equalsIgnoreCase(name)) { target=candidate; break; }
+            if (image!=null && (embedded || name.isEmpty() || sameFilename(name,image.name))) target=image;
+            if (image==null) target=findAudio(folder,name);
             if (target!=null) result.add(new Track(target.uri,part.number+". "+part.title,part.start,part.end));
             else if (image==null) return new ArrayList<>(); // Never play a partial/misresolved sheet.
         }
diff --git a/scripts/test-cue-filenames.py b/scripts/test-cue-filenames.py
new file mode 100644
index 0000000..08e5535
--- /dev/null
+++ b/scripts/test-cue-filenames.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""Test actual MusicLibrary filename resolution with host Android stubs (JDK 17+).
+Optionally pass a UTF-8 CUE file to test it against an NFD audio filename too.
+"""
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+
+repo = Path(__file__).resolve().parents[1]
+with tempfile.TemporaryDirectory(prefix='loopdeck-cue-') as temp:
+    root = Path(temp)
+
+    def stub(name, body, base=''):
+        package, cls = name.rsplit('/', 1)
+        path = root / (name + '.java')
+        path.parent.mkdir(parents=True, exist_ok=True)
+        path.write_text('package ' + package.replace('/', '.') + '; public class ' + cls + base + ' {' + body + '}', encoding='utf-8')
+
+    stub('android/net/Uri', 'final String id; public Uri(String s){id=s;} public String toString(){return id;}')
+    stub('android/os/ParcelFileDescriptor', 'public int getFd(){return 1;} public void close(){}', ' implements AutoCloseable')
+    stub('android/database/Cursor', 'public boolean moveToNext(){return false;} public String getString(int n){return null;} public void close(){}', ' implements AutoCloseable')
+    stub('android/content/Context', 'public final ContentResolver resolver=new ContentResolver(); public ContentResolver getContentResolver(){return resolver;}')
+    stub('android/content/ContentResolver', '''
+        public String cue;
+        public android.database.Cursor query(android.net.Uri u,String[] c,String a,String[] b,String d){return new android.database.Cursor();}
+        public java.io.InputStream openInputStream(android.net.Uri u){return new java.io.ByteArrayInputStream(cue.getBytes(java.nio.charset.StandardCharsets.UTF_8));}
+        public android.os.ParcelFileDescriptor openFileDescriptor(android.net.Uri u,String m){return new android.os.ParcelFileDescriptor();}
+    ''')
+    stub('android/provider/DocumentsContract', '''
+        public static android.net.Uri buildChildDocumentsUriUsingTree(android.net.Uri u,String s){return new android.net.Uri(s);}
+        public static android.net.Uri buildDocumentUriUsingTree(android.net.Uri u,String s){return new android.net.Uri(s);}
+        public static class Document {public static final String COLUMN_DOCUMENT_ID="id",COLUMN_DISPLAY_NAME="name",COLUMN_MIME_TYPE="mime",MIME_TYPE_DIR="dir";}
+    ''')
+    stub('net/astropenguin/loopdeck/NativeDecoder', '''
+        static long open(int fd){return 1;} static void close(long h){}
+        static String cueSheet(long h){return "";} static String[] chapters(long h){return new String[0];} static long duration(long h){return 120000;}
+    ''')
+    stub('net/astropenguin/loopdeck/FilenameTest', r'''
+        static void expect(boolean ok){if(!ok)throw new AssertionError();}
+        static MusicLibrary.Entry entry(String id,String name){return new MusicLibrary.Entry(id,name,"application/octet-stream",new android.net.Uri(id));}
+        static String sheet(String name){return "\ufeffFILE \""+name+"\" WAVE\r\nTRACK 01 AUDIO\r\nINDEX 01 00:00:00\r\nTRACK 02 AUDIO\r\nINDEX 01 01:00:00\r\n";}
+        static void rejected(android.content.Context context,MusicLibrary.Entry cue,java.util.List<MusicLibrary.Entry> folder)throws Exception {
+            try{MusicLibrary.tracks(context,cue,folder);throw new AssertionError("accepted unresolved sheet");}
+            catch(java.io.IOException expected){}
+        }
+        public static void main(String[] args)throws Exception {
+            android.content.Context context=new android.content.Context();
+            String nfc="聖骸メロフォビア.tta", nfd=java.text.Normalizer.normalize(nfc,java.text.Normalizer.Form.NFD);
+            expect(!nfc.equals(nfd));
+            MusicLibrary.Entry cue=entry("cue","album.cue"), audio=entry("audio",nfd);
+            context.resolver.cue=sheet(nfc);
+            java.util.List<Track> tracks=MusicLibrary.tracks(context,cue,java.util.List.of(cue,audio));
+            expect(tracks.size()==2 && tracks.get(0).uri==audio.uri && tracks.get(0).endMs==60000);
+            expect(MusicLibrary.tracks(context,audio,java.util.List.of(cue,audio)).size()==2);
+            // Also support decomposed FILE references and composed storage names.
+            context.resolver.cue=sheet(nfd);audio=entry("composed",nfc);
+            expect(MusicLibrary.tracks(context,cue,java.util.List.of(cue,audio)).size()==2);
+            // Existing literal matches win when both spellings exist.
+            MusicLibrary.Entry decomposed=entry("decomposed",nfd);
+            expect(MusicLibrary.tracks(context,cue,java.util.List.of(cue,audio,decomposed)).get(0).uri==decomposed.uri);
+            // Missing files and multiple fallback matches must still fail.
+            context.resolver.cue=sheet(nfc);
+            rejected(context,cue,java.util.List.of(cue,entry("wrong","other.tta")));
+            rejected(context,cue,java.util.List.of(cue,decomposed,entry("duplicate",nfd)));
+            context.resolver.cue=sheet("./ALBUM.TTA");
+            expect(MusicLibrary.tracks(context,cue,java.util.List.of(cue,entry("ascii","album.tta"))).size()==2);
+            // Canonical equivalence only: fullwidth ASCII is a distinct filename.
+            context.resolver.cue=sheet("A.tta");
+            rejected(context,cue,java.util.List.of(cue,entry("ascii","A.tta")));
+            if(args.length>0){
+                context.resolver.cue=java.nio.file.Files.readString(java.nio.file.Path.of(args[0]));
+                java.util.List<CueSheet.Part> parts=CueSheet.parse(context.resolver.cue);
+                expect(!parts.isEmpty());
+                String filename=java.text.Normalizer.normalize(parts.get(0).file,java.text.Normalizer.Form.NFD);
+                MusicLibrary.Entry image=entry("uploaded-audio",filename);
+                tracks=MusicLibrary.tracks(context,cue,java.util.List.of(cue,image));
+                expect(tracks.size()==parts.size());
+                for(int i=0;i<tracks.size();i++)expect(tracks.get(i).startMs==parts.get(i).start && tracks.get(i).uri==image.uri);
+                expect(MusicLibrary.tracks(context,image,java.util.List.of(cue,image)).size()==parts.size());
+                System.out.println("Uploaded CUE: "+tracks.size()+" tracks resolved with decomposed audio filename");
+            }
+            System.out.println("CUE filenames: NFC/NFD both directions, audio/CUE selection, literal precedence, missing/ambiguous rejection and ASCII compatibility passed");
+        }
+    ''')
+    app = repo / 'app/src/main/java/net/astropenguin/loopdeck'
+    sources = list(root.rglob('*.java')) + [app / (n + '.java') for n in ('MusicLibrary', 'CueSheet', 'Track')]
+    subprocess.run(['java', '-m', 'jdk.compiler/com.sun.tools.javac.Main', '--release', '17', '-encoding', 'UTF-8', '-d', str(root / 'classes'), *map(str, sources)], check=True)
+    subprocess.run(['java', '-cp', str(root / 'classes'), 'net.astropenguin.loopdeck.FilenameTest', *sys.argv[1:]], check=True)